mirror of
https://github.com/github/spec-kit.git
synced 2026-02-01 13:33:37 +00:00
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:
30
README.md
30
README.md
@@ -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
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user