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 | | `--here` | Flag | Initialize project in the current directory instead of creating a new one |
| `--skip-tls` | Flag | Skip SSL/TLS verification (not recommended) | | `--skip-tls` | Flag | Skip SSL/TLS verification (not recommended) |
| `--debug` | Flag | Enable detailed debug output for troubleshooting | | `--debug` | Flag | Enable detailed debug output for troubleshooting |
| `--use-apm` | Flag | Include APM (Agent Package Manager) structure for context management |
### Examples ### Examples
@@ -107,14 +108,17 @@ specify init my-project
# Initialize with specific AI assistant # Initialize with specific AI assistant
specify init my-project --ai claude specify init my-project --ai claude
# Initialize with APM support
specify init my-project --ai claude --use-apm
# Initialize with Cursor support # Initialize with Cursor support
specify init my-project --ai cursor specify init my-project --ai cursor
# Initialize with PowerShell scripts (Windows/cross-platform) # Initialize with PowerShell scripts (Windows/cross-platform)
specify init my-project --ai copilot --script ps specify init my-project --ai copilot --script ps
# Initialize in current directory # Initialize in current directory with APM
specify init --here --ai copilot specify init --here --ai copilot --use-apm
# Skip git initialization # Skip git initialization
specify init my-project --ai gemini --no-git specify init my-project --ai gemini --no-git
@@ -139,9 +143,25 @@ specify init my-project --ai claude
### APM Commands ### APM Commands
```bash ```bash
# Core APM commands available under 'apm' subcommand # Core APM commands available under 'apm' subcommand
specify apm compile # Generate AGENTS.md from your context
specify apm install # Install APM package dependencies # Install APM packages from apm.yml
specify apm deps list # List available APM packages 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 ## <20>📚 Core philosophy

View File

@@ -116,7 +116,53 @@ def _lazy_confirm():
return None 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): 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}") _rich_error(f"Error initializing project: {e}")
sys.exit(1) 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") @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('--runtime', help="Target specific runtime only (copilot, codex, vscode)")
@click.option('--exclude', help="Exclude specific runtime from installation") @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('--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('--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.option('--dry-run', is_flag=True, help="Show what would be installed without installing")
@click.pass_context @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). """Install APM and MCP dependencies from apm.yml (like npm install).
This command automatically detects AI runtimes from your apm.yml scripts and installs 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. dependencies from GitHub repositories.
Examples: Examples:
apm install # Install APM deps then MCP deps for all runtimes apm install # Install existing deps from apm.yml
apm install --exclude codex # Install for all except Codex CLI apm install org/pkg1 # Add package to apm.yml and install
apm install --only=apm # Install only APM dependencies apm install org/pkg1 org/pkg2 # Add multiple packages and install
apm install --only=mcp # Install only MCP dependencies apm install --exclude codex # Install for all except Codex CLI
apm install --update # Update dependencies to latest Git refs apm install --only=apm # Install only APM dependencies
apm install --dry-run # Show what would be installed 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: try:
# Check if apm.yml exists # 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.") _rich_error("No apm.yml found. Run 'apm init' first.")
sys.exit(1) 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...") _rich_info("Installing dependencies from apm.yml...")
# Parse apm.yml to get both APM and MCP dependencies # 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) 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): def _install_apm_dependencies(apm_package: 'APMPackage', update_refs: bool = False):
"""Install APM package dependencies. """Install APM package dependencies.
@@ -412,13 +816,22 @@ def _install_apm_dependencies(apm_package: 'APMPackage', update_refs: bool = Fal
installed_count = 0 installed_count = 0
for dep_ref in deps_to_install: 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 dep_ref.alias:
# If alias is provided, use it directly (assume user handles namespacing)
install_name = dep_ref.alias install_name = dep_ref.alias
install_path = apm_modules_dir / install_name
else: else:
install_name = dep_ref.repo_url.split('/')[-1] # Use org/repo structure to prevent collisions
repo_parts = dep_ref.repo_url.split('/')
install_path = apm_modules_dir / install_name 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 # Skip if already exists and not updating
if install_path.exists() and not update_refs: 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: 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_warning("No APM dependencies, local .apm/ directory, or constitution found")
_rich_info("💡 Nothing to compile. To get started:") _rich_info("💡 Nothing to compile. To get started:")
_rich_info(" 1. Install APM dependencies: apm install") _rich_info(" 1. Install APM dependencies: specify apm install")
_rich_info(" 2. Or initialize APM project: apm init") _rich_info(" 2. Or initialize APM project: specify apm init")
_rich_info(" 3. Then run: apm compile") _rich_info(" 3. Then run: specify apm compile")
return return
except Exception: except Exception:
pass # Continue with compilation if check fails 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}") click.echo(f"{error}")
sys.exit(1) 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: except ImportError as e:
_rich_error(f"Compilation module not available: {e}") _rich_error(f"Compilation module not available: {e}")
_rich_info("This might be a development environment issue.") _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 not apm_modules_path.exists():
if has_rich: if has_rich:
console.print("💡 No APM dependencies installed yet", style="cyan") 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: else:
click.echo("💡 No APM dependencies installed yet") 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 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 = [] installed_packages = []
for package_dir in apm_modules_path.iterdir(): orphaned_packages = []
if package_dir.is_dir(): for org_dir in apm_modules_path.iterdir():
try: if org_dir.is_dir() and not org_dir.name.startswith('.'):
# Try to load package metadata for package_dir in org_dir.iterdir():
apm_yml_path = package_dir / "apm.yml" if package_dir.is_dir() and not package_dir.name.startswith('.'):
if apm_yml_path.exists(): try:
package = APMPackage.from_apm_yml(apm_yml_path) # org/repo format
# Count context files and workflows separately org_repo_name = f"{org_dir.name}/{package_dir.name}"
context_count, workflow_count = _count_package_files(package_dir)
installed_packages.append({ # Try to load package metadata
'name': package.name, apm_yml_path = package_dir / "apm.yml"
'version': package.version or 'unknown', if apm_yml_path.exists():
'source': package.source or 'local', package = APMPackage.from_apm_yml(apm_yml_path)
'context': context_count, # Count context files and workflows separately
'workflows': workflow_count, context_count, workflow_count = _count_package_files(package_dir)
'path': package_dir.name
}) # Check if this package is orphaned
else: is_orphaned = org_repo_name not in declared_deps
# Package without apm.yml - show basic info if is_orphaned:
context_count, workflow_count = _count_package_files(package_dir) orphaned_packages.append(org_repo_name)
installed_packages.append({
'name': package_dir.name, installed_packages.append({
'version': 'unknown', 'name': org_repo_name,
'source': 'unknown', 'version': package.version or 'unknown',
'context': context_count, 'source': 'orphaned' if is_orphaned else 'github',
'workflows': workflow_count, 'context': context_count,
'path': package_dir.name 'workflows': workflow_count,
}) 'path': str(package_dir),
except Exception as e: 'is_orphaned': is_orphaned
click.echo(f"⚠️ Warning: Failed to read package {package_dir.name}: {e}") })
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 not installed_packages:
if has_rich: if has_rich:
@@ -108,6 +136,13 @@ def list_packages():
) )
console.print(table) 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: else:
# Fallback text table # Fallback text table
click.echo("📋 APM Dependencies:") click.echo("📋 APM Dependencies:")
@@ -125,6 +160,13 @@ def list_packages():
click.echo("└─────────────────────┴─────────┴──────────────┴─────────────┴─────────────┘") 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: except Exception as e:
_rich_error(f"Error listing dependencies: {e}") _rich_error(f"Error listing dependencies: {e}")
sys.exit(1) sys.exit(1)
@@ -318,7 +360,7 @@ def info(package: str):
if not apm_modules_path.exists(): if not apm_modules_path.exists():
_rich_error("No apm_modules/ directory found") _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) sys.exit(1)
# Find the package directory # Find the package directory

View File

@@ -157,7 +157,14 @@ def scan_dependency_primitives(base_dir: str, collection: PrimitiveCollection) -
# Process dependencies in declaration order # Process dependencies in declaration order
for dep_name in dependency_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(): if dep_path.exists() and dep_path.is_dir():
scan_directory_with_source(dep_path, collection, source=f"dependency:{dep_name}") 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() apm_dependencies = package.get_apm_dependencies()
# Extract package names from dependency references # 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 = [] dependency_names = []
for dep in apm_dependencies: for dep in apm_dependencies:
if dep.alias: if dep.alias:
dependency_names.append(dep.alias) dependency_names.append(dep.alias)
else: else:
# Extract repository name from repo_url (e.g., "user/repo" -> "repo") # Use full org/repo path (e.g., "github/design-guidelines")
repo_name = dep.repo_url.split("/")[-1] # This matches our org-namespaced directory structure
dependency_names.append(repo_name) dependency_names.append(dep.repo_url)
return dependency_names return dependency_names

View File

@@ -55,7 +55,7 @@ from rich.tree import Tree
from typer.core import TyperGroup from typer.core import TyperGroup
# APM imports # 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 from apm_cli.commands.deps import deps as apm_deps
import click import click
from click.testing import CliRunner from click.testing import CliRunner
@@ -318,7 +318,9 @@ def apm_click():
# Add APM commands to the Click group # Add APM commands to the Click group
apm_click.add_command(apm_init, name="init") apm_click.add_command(apm_init, name="init")
apm_click.add_command(apm_install, name="install") 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_compile, name="compile")
apm_click.add_command(apm_prune, name="prune")
apm_click.add_command(apm_deps, name="deps") 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}) @apm_app.command("install", context_settings={"allow_extra_args": True, "allow_interspersed_args": False})
def apm_install_wrapper( def apm_install_wrapper(
ctx: typer.Context, 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)"), 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"), 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)"), 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"), 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"), 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 = [] args = []
# Add package arguments first
if packages:
args.extend(packages)
if runtime: if runtime:
args.extend(["--runtime", runtime]) args.extend(["--runtime", runtime])
if exclude: if exclude:
@@ -422,6 +436,61 @@ def apm_compile_wrapper(
_run_apm_command(["compile"] + args) _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 # Create deps subcommands as Typer sub-application
deps_app = typer.Typer( deps_app = typer.Typer(
name="deps", 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"), 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)"), 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"), 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. 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 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) 5. Initialize a fresh git repository (if not --no-git and no existing repo)
6. Optionally set up AI assistant commands 6. Optionally set up AI assistant commands
7. Optionally include APM support (with --use-apm flag)
Examples: Examples:
specify init my-project specify init my-project
specify init my-project --ai claude 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 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 --ignore-agent-tools my-project
specify init --here --ai claude specify init --here --ai claude
specify init --here specify init --here --use-apm
""" """
# Show banner first # Show banner first
show_banner() 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) download_and_extract_template(project_path, selected_ai, selected_script, here, verbose=False, tracker=tracker, client=local_client, debug=debug)
# APM structure creation # APM structure creation (conditional)
tracker.start("apm", "setting up APM structure") if use_apm:
try: tracker.start("apm", "setting up APM structure")
_create_apm_structure(project_path, project_path.name, selected_ai) try:
tracker.complete("apm", "APM structure created") _create_apm_structure(project_path, project_path.name, selected_ai)
except Exception as e: tracker.complete("apm", "APM structure created")
tracker.error("apm", f"APM setup failed: {str(e)}") 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 scripts are executable (POSIX)
ensure_executable_scripts(project_path, tracker=tracker) ensure_executable_scripts(project_path, tracker=tracker)
@@ -1192,12 +1266,13 @@ def init(
step_num += 1 step_num += 1
steps_lines.append(f"{step_num}. Update [bold magenta]CONSTITUTION.md[/bold magenta] with your project's non-negotiable principles") 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 # Add APM-specific next steps if APM was enabled
step_num += 1 if use_apm:
steps_lines.append(f"{step_num}. Use APM commands to manage your AI-native project:") step_num += 1
steps_lines.append(" - [bold cyan]specify apm compile[/bold cyan] - Generate AGENTS.md from your context") steps_lines.append(f"{step_num}. Use APM commands to manage your project context:")
steps_lines.append(" - [bold cyan]specify apm install[/bold cyan] - Install APM package dependencies") 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 deps list[/bold cyan] - List available APM 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)) steps_panel = Panel("\n".join(steps_lines), title="Next steps", border_style="cyan", padding=(1,2))
console.print() # blank line console.print() # blank line