From 2f6e3c8401c1c5a04095f6e18b5fe8f4338515af Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 16 Apr 2026 17:07:56 -0500 Subject: [PATCH 01/11] Replace shell-based context updates with marker-based upsert Replace ~3500 lines of bash/PowerShell agent context update scripts with a Python-based approach using markers. IntegrationBase now manages the agent context file directly: - upsert_context_section(): creates or updates the marked section at init/install/switch time with a directive to read the current plan - remove_context_section(): removes the section at uninstall, deleting the file only if it becomes empty - __CONTEXT_FILE__ placeholder in command templates is resolved per integration so the plan command references the correct agent file - context_file is persisted in init-options.json for extension access The plan command template instructs the LLM to update the plan reference between the markers in the agent context file. Removed: - scripts/bash/update-agent-context.sh (857 lines) - scripts/powershell/update-agent-context.ps1 (515 lines) - 56 integration wrapper scripts (update-context.sh/.ps1) - templates/agent-file-template.md - agent_scripts frontmatter key and {AGENT_SCRIPT} replacement logic - update-context reference from integration.json - tests/test_cursor_frontmatter.py (tested deleted scripts) Added: - upsert/remove context section methods on IntegrationBase - __CONTEXT_FILE__ placeholder support in process_template() - context_file field in init-options.json (init/switch/uninstall) - Per-integration tests: context file correctness, plan reference, init-options persistence (78 new context_file tests) - End-to-end CLI validation across all 28 integrations --- pyproject.toml | 1 - scripts/bash/update-agent-context.sh | 857 ------------------ scripts/powershell/update-agent-context.ps1 | 515 ----------- src/specify_cli/__init__.py | 12 +- src/specify_cli/agents.py | 30 +- .../agy/scripts/update-context.ps1 | 17 - .../agy/scripts/update-context.sh | 24 - .../amp/scripts/update-context.ps1 | 23 - .../amp/scripts/update-context.sh | 28 - .../auggie/scripts/update-context.ps1 | 23 - .../auggie/scripts/update-context.sh | 28 - src/specify_cli/integrations/base.py | 189 +++- .../bob/scripts/update-context.ps1 | 23 - .../bob/scripts/update-context.sh | 28 - .../claude/scripts/update-context.ps1 | 23 - .../claude/scripts/update-context.sh | 28 - .../codebuddy/scripts/update-context.ps1 | 23 - .../codebuddy/scripts/update-context.sh | 28 - .../codex/scripts/update-context.ps1 | 17 - .../codex/scripts/update-context.sh | 24 - .../integrations/copilot/__init__.py | 9 +- .../copilot/scripts/update-context.ps1 | 32 - .../copilot/scripts/update-context.sh | 37 - .../cursor_agent/scripts/update-context.ps1 | 23 - .../cursor_agent/scripts/update-context.sh | 28 - .../integrations/forge/__init__.py | 9 +- .../forge/scripts/update-context.ps1 | 33 - .../forge/scripts/update-context.sh | 38 - .../gemini/scripts/update-context.ps1 | 23 - .../gemini/scripts/update-context.sh | 28 - .../integrations/generic/__init__.py | 11 +- .../generic/scripts/update-context.ps1 | 17 - .../generic/scripts/update-context.sh | 24 - .../goose/scripts/update-context.ps1 | 33 - .../goose/scripts/update-context.sh | 38 - .../iflow/scripts/update-context.ps1 | 23 - .../iflow/scripts/update-context.sh | 28 - .../junie/scripts/update-context.ps1 | 23 - .../junie/scripts/update-context.sh | 28 - .../kilocode/scripts/update-context.ps1 | 23 - .../kilocode/scripts/update-context.sh | 28 - .../kimi/scripts/update-context.ps1 | 17 - .../kimi/scripts/update-context.sh | 24 - .../kiro_cli/scripts/update-context.ps1 | 23 - .../kiro_cli/scripts/update-context.sh | 28 - .../opencode/scripts/update-context.ps1 | 23 - .../opencode/scripts/update-context.sh | 28 - .../pi/scripts/update-context.ps1 | 23 - .../integrations/pi/scripts/update-context.sh | 28 - .../qodercli/scripts/update-context.ps1 | 23 - .../qodercli/scripts/update-context.sh | 28 - .../qwen/scripts/update-context.ps1 | 23 - .../qwen/scripts/update-context.sh | 28 - .../roo/scripts/update-context.ps1 | 23 - .../roo/scripts/update-context.sh | 28 - .../shai/scripts/update-context.ps1 | 23 - .../shai/scripts/update-context.sh | 28 - .../tabnine/scripts/update-context.ps1 | 23 - .../tabnine/scripts/update-context.sh | 28 - .../trae/scripts/update-context.ps1 | 23 - .../trae/scripts/update-context.sh | 28 - .../vibe/scripts/update-context.ps1 | 23 - .../vibe/scripts/update-context.sh | 28 - .../windsurf/scripts/update-context.ps1 | 23 - .../windsurf/scripts/update-context.sh | 28 - templates/agent-file-template.md | 28 - templates/commands/plan.md | 11 +- tests/integrations/test_cli.py | 11 +- .../test_integration_base_markdown.py | 96 +- .../test_integration_base_skills.py | 87 +- .../test_integration_base_toml.py | 98 +- .../test_integration_base_yaml.py | 98 +- .../integrations/test_integration_catalog.py | 4 +- tests/integrations/test_integration_claude.py | 20 +- .../integrations/test_integration_copilot.py | 25 +- tests/integrations/test_integration_forge.py | 32 +- .../integrations/test_integration_generic.py | 71 +- tests/test_agent_config_consistency.py | 119 --- tests/test_cursor_frontmatter.py | 266 ------ tests/test_extension_skills.py | 5 - tests/test_extensions.py | 18 - tests/test_presets.py | 2 - 82 files changed, 555 insertions(+), 3515 deletions(-) delete mode 100644 scripts/bash/update-agent-context.sh delete mode 100644 scripts/powershell/update-agent-context.ps1 delete mode 100644 src/specify_cli/integrations/agy/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/agy/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/amp/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/amp/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/auggie/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/auggie/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/bob/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/bob/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/claude/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/claude/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/codebuddy/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/codebuddy/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/codex/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/codex/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/copilot/scripts/update-context.ps1 delete mode 100644 src/specify_cli/integrations/copilot/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/cursor_agent/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/cursor_agent/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/forge/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/forge/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/gemini/scripts/update-context.ps1 delete mode 100644 src/specify_cli/integrations/gemini/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/generic/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/generic/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/goose/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/goose/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/iflow/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/iflow/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/junie/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/junie/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/kilocode/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/kilocode/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/kimi/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/kimi/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/kiro_cli/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/kiro_cli/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/opencode/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/opencode/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/pi/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/pi/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/qodercli/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/qodercli/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/qwen/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/qwen/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/roo/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/roo/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/shai/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/shai/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/tabnine/scripts/update-context.ps1 delete mode 100644 src/specify_cli/integrations/tabnine/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/trae/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/trae/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/vibe/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/vibe/scripts/update-context.sh delete mode 100644 src/specify_cli/integrations/windsurf/scripts/update-context.ps1 delete mode 100755 src/specify_cli/integrations/windsurf/scripts/update-context.sh delete mode 100644 templates/agent-file-template.md delete mode 100644 tests/test_cursor_frontmatter.py diff --git a/pyproject.toml b/pyproject.toml index fc4b306351..dae79b0f6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,6 @@ packages = ["src/specify_cli"] [tool.hatch.build.targets.wheel.force-include] # Bundle core assets so `specify init` works without network access (air-gapped / enterprise) # Page templates (exclude commands/ — bundled separately below to avoid duplication) -"templates/agent-file-template.md" = "specify_cli/core_pack/templates/agent-file-template.md" "templates/checklist-template.md" = "specify_cli/core_pack/templates/checklist-template.md" "templates/constitution-template.md" = "specify_cli/core_pack/templates/constitution-template.md" "templates/plan-template.md" = "specify_cli/core_pack/templates/plan-template.md" diff --git a/scripts/bash/update-agent-context.sh b/scripts/bash/update-agent-context.sh deleted file mode 100644 index 2f71bb893c..0000000000 --- a/scripts/bash/update-agent-context.sh +++ /dev/null @@ -1,857 +0,0 @@ -#!/usr/bin/env bash - -# Update agent context files with information from plan.md -# -# This script maintains AI agent context files by parsing feature specifications -# and updating agent-specific configuration files with project information. -# -# MAIN FUNCTIONS: -# 1. Environment Validation -# - Verifies git repository structure and branch information -# - Checks for required plan.md files and templates -# - Validates file permissions and accessibility -# -# 2. Plan Data Extraction -# - Parses plan.md files to extract project metadata -# - Identifies language/version, frameworks, databases, and project types -# - Handles missing or incomplete specification data gracefully -# -# 3. Agent File Management -# - Creates new agent context files from templates when needed -# - Updates existing agent files with new project information -# - Preserves manual additions and custom configurations -# - Supports multiple AI agent formats and directory structures -# -# 4. Content Generation -# - Generates language-specific build/test commands -# - Creates appropriate project directory structures -# - Updates technology stacks and recent changes sections -# - Maintains consistent formatting and timestamps -# -# 5. Multi-Agent Support -# - Handles agent-specific file paths and naming conventions -# - Supports: Claude, Gemini, Copilot, Cursor, Qwen, opencode, Codex, Windsurf, Junie, Kilo Code, Auggie CLI, Roo Code, CodeBuddy CLI, Qoder CLI, Amp, SHAI, Tabnine CLI, Kiro CLI, Mistral Vibe, Kimi Code, Pi Coding Agent, iFlow CLI, Forge, Goose, Antigravity or Generic -# - Can update single agents or all existing agent files -# - Creates default Claude file if no agent files exist -# -# Usage: ./update-agent-context.sh [agent_type] -# Agent types: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|goose|generic -# Leave empty to update all existing agent files - -set -e - -# Enable strict error handling -set -u -set -o pipefail - -#============================================================================== -# Configuration and Global Variables -#============================================================================== - -# Get script directory and load common functions -SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/common.sh" - -# Get all paths and variables from common functions -_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; } -eval "$_paths_output" -unset _paths_output - -NEW_PLAN="$IMPL_PLAN" # Alias for compatibility with existing code -AGENT_TYPE="${1:-}" - -# Agent-specific file paths -CLAUDE_FILE="$REPO_ROOT/CLAUDE.md" -GEMINI_FILE="$REPO_ROOT/GEMINI.md" -COPILOT_FILE="$REPO_ROOT/.github/copilot-instructions.md" -CURSOR_FILE="$REPO_ROOT/.cursor/rules/specify-rules.mdc" -QWEN_FILE="$REPO_ROOT/QWEN.md" -AGENTS_FILE="$REPO_ROOT/AGENTS.md" -WINDSURF_FILE="$REPO_ROOT/.windsurf/rules/specify-rules.md" -JUNIE_FILE="$REPO_ROOT/.junie/AGENTS.md" -KILOCODE_FILE="$REPO_ROOT/.kilocode/rules/specify-rules.md" -AUGGIE_FILE="$REPO_ROOT/.augment/rules/specify-rules.md" -ROO_FILE="$REPO_ROOT/.roo/rules/specify-rules.md" -CODEBUDDY_FILE="$REPO_ROOT/CODEBUDDY.md" -QODER_FILE="$REPO_ROOT/QODER.md" -# Amp, Kiro CLI, IBM Bob, Pi, Forge, and Goose all share AGENTS.md — use AGENTS_FILE to avoid -# updating the same file multiple times. -AMP_FILE="$AGENTS_FILE" -SHAI_FILE="$REPO_ROOT/SHAI.md" -TABNINE_FILE="$REPO_ROOT/TABNINE.md" -KIRO_FILE="$AGENTS_FILE" -AGY_FILE="$REPO_ROOT/.agent/rules/specify-rules.md" -BOB_FILE="$AGENTS_FILE" -VIBE_FILE="$REPO_ROOT/.vibe/agents/specify-agents.md" -KIMI_FILE="$REPO_ROOT/KIMI.md" -TRAE_FILE="$REPO_ROOT/.trae/rules/project_rules.md" -IFLOW_FILE="$REPO_ROOT/IFLOW.md" -FORGE_FILE="$AGENTS_FILE" - -# Template file -TEMPLATE_FILE="$REPO_ROOT/.specify/templates/agent-file-template.md" - -# Global variables for parsed plan data -NEW_LANG="" -NEW_FRAMEWORK="" -NEW_DB="" -NEW_PROJECT_TYPE="" - -#============================================================================== -# Utility Functions -#============================================================================== - -log_info() { - echo "INFO: $1" -} - -log_success() { - echo "✓ $1" -} - -log_error() { - echo "ERROR: $1" >&2 -} - -log_warning() { - echo "WARNING: $1" >&2 -} - -# Track temporary files for cleanup on interrupt -_CLEANUP_FILES=() - -# Cleanup function for temporary files -cleanup() { - local exit_code=$? - # Disarm traps to prevent re-entrant loop - trap - EXIT INT TERM - if [ ${#_CLEANUP_FILES[@]} -gt 0 ]; then - for f in "${_CLEANUP_FILES[@]}"; do - rm -f "$f" "$f.bak" "$f.tmp" - done - fi - exit $exit_code -} - -# Set up cleanup trap -trap cleanup EXIT INT TERM - -#============================================================================== -# Validation Functions -#============================================================================== - -validate_environment() { - # Check if we have a current branch/feature (git or non-git) - if [[ -z "$CURRENT_BRANCH" ]]; then - log_error "Unable to determine current feature" - if [[ "$HAS_GIT" == "true" ]]; then - log_info "Make sure you're on a feature branch" - else - log_info "Set SPECIFY_FEATURE environment variable or create a feature first" - fi - exit 1 - fi - - # Check if plan.md exists - if [[ ! -f "$NEW_PLAN" ]]; then - log_error "No plan.md found at $NEW_PLAN" - log_info "Make sure you're working on a feature with a corresponding spec directory" - if [[ "$HAS_GIT" != "true" ]]; then - log_info "Use: export SPECIFY_FEATURE=your-feature-name or create a new feature first" - fi - exit 1 - fi - - # Check if template exists (needed for new files) - if [[ ! -f "$TEMPLATE_FILE" ]]; then - log_warning "Template file not found at $TEMPLATE_FILE" - log_warning "Creating new agent files will fail" - fi -} - -#============================================================================== -# Plan Parsing Functions -#============================================================================== - -extract_plan_field() { - local field_pattern="$1" - local plan_file="$2" - - grep "^\*\*${field_pattern}\*\*: " "$plan_file" 2>/dev/null | \ - head -1 | \ - sed "s|^\*\*${field_pattern}\*\*: ||" | \ - sed 's/^[ \t]*//;s/[ \t]*$//' | \ - grep -v "NEEDS CLARIFICATION" | \ - grep -v "^N/A$" || echo "" -} - -parse_plan_data() { - local plan_file="$1" - - if [[ ! -f "$plan_file" ]]; then - log_error "Plan file not found: $plan_file" - return 1 - fi - - if [[ ! -r "$plan_file" ]]; then - log_error "Plan file is not readable: $plan_file" - return 1 - fi - - log_info "Parsing plan data from $plan_file" - - NEW_LANG=$(extract_plan_field "Language/Version" "$plan_file") - NEW_FRAMEWORK=$(extract_plan_field "Primary Dependencies" "$plan_file") - NEW_DB=$(extract_plan_field "Storage" "$plan_file") - NEW_PROJECT_TYPE=$(extract_plan_field "Project Type" "$plan_file") - - # Log what we found - if [[ -n "$NEW_LANG" ]]; then - log_info "Found language: $NEW_LANG" - else - log_warning "No language information found in plan" - fi - - if [[ -n "$NEW_FRAMEWORK" ]]; then - log_info "Found framework: $NEW_FRAMEWORK" - fi - - if [[ -n "$NEW_DB" ]] && [[ "$NEW_DB" != "N/A" ]]; then - log_info "Found database: $NEW_DB" - fi - - if [[ -n "$NEW_PROJECT_TYPE" ]]; then - log_info "Found project type: $NEW_PROJECT_TYPE" - fi -} - -format_technology_stack() { - local lang="$1" - local framework="$2" - local parts=() - - # Add non-empty parts - [[ -n "$lang" && "$lang" != "NEEDS CLARIFICATION" ]] && parts+=("$lang") - [[ -n "$framework" && "$framework" != "NEEDS CLARIFICATION" && "$framework" != "N/A" ]] && parts+=("$framework") - - # Join with proper formatting - if [[ ${#parts[@]} -eq 0 ]]; then - echo "" - elif [[ ${#parts[@]} -eq 1 ]]; then - echo "${parts[0]}" - else - # Join multiple parts with " + " - local result="${parts[0]}" - for ((i=1; i<${#parts[@]}; i++)); do - result="$result + ${parts[i]}" - done - echo "$result" - fi -} - -#============================================================================== -# Template and Content Generation Functions -#============================================================================== - -get_project_structure() { - local project_type="$1" - - if [[ "$project_type" == *"web"* ]]; then - echo "backend/\\nfrontend/\\ntests/" - else - echo "src/\\ntests/" - fi -} - -get_commands_for_language() { - local lang="$1" - - case "$lang" in - *"Python"*) - echo "cd src && pytest && ruff check ." - ;; - *"Rust"*) - echo "cargo test && cargo clippy" - ;; - *"JavaScript"*|*"TypeScript"*) - echo "npm test && npm run lint" - ;; - *) - echo "# Add commands for $lang" - ;; - esac -} - -get_language_conventions() { - local lang="$1" - echo "$lang: Follow standard conventions" -} - -# Escape sed replacement-side specials for | delimiter. -# & and \ are replacement-side specials; | is our sed delimiter. -_esc_sed() { printf '%s\n' "$1" | sed 's/[\\&|]/\\&/g'; } - -create_new_agent_file() { - local target_file="$1" - local temp_file="$2" - local project_name - project_name=$(_esc_sed "$3") - local current_date="$4" - - if [[ ! -f "$TEMPLATE_FILE" ]]; then - log_error "Template not found at $TEMPLATE_FILE" - return 1 - fi - - if [[ ! -r "$TEMPLATE_FILE" ]]; then - log_error "Template file is not readable: $TEMPLATE_FILE" - return 1 - fi - - log_info "Creating new agent context file from template..." - - if ! cp "$TEMPLATE_FILE" "$temp_file"; then - log_error "Failed to copy template file" - return 1 - fi - - # Replace template placeholders - local project_structure - project_structure=$(get_project_structure "$NEW_PROJECT_TYPE") - project_structure=$(_esc_sed "$project_structure") - - local commands - commands=$(get_commands_for_language "$NEW_LANG") - - local language_conventions - language_conventions=$(get_language_conventions "$NEW_LANG") - - local escaped_lang=$(_esc_sed "$NEW_LANG") - local escaped_framework=$(_esc_sed "$NEW_FRAMEWORK") - commands=$(_esc_sed "$commands") - language_conventions=$(_esc_sed "$language_conventions") - local escaped_branch=$(_esc_sed "$CURRENT_BRANCH") - - # Build technology stack and recent change strings conditionally - local tech_stack - if [[ -n "$escaped_lang" && -n "$escaped_framework" ]]; then - tech_stack="- $escaped_lang + $escaped_framework ($escaped_branch)" - elif [[ -n "$escaped_lang" ]]; then - tech_stack="- $escaped_lang ($escaped_branch)" - elif [[ -n "$escaped_framework" ]]; then - tech_stack="- $escaped_framework ($escaped_branch)" - else - tech_stack="- ($escaped_branch)" - fi - - local recent_change - if [[ -n "$escaped_lang" && -n "$escaped_framework" ]]; then - recent_change="- $escaped_branch: Added $escaped_lang + $escaped_framework" - elif [[ -n "$escaped_lang" ]]; then - recent_change="- $escaped_branch: Added $escaped_lang" - elif [[ -n "$escaped_framework" ]]; then - recent_change="- $escaped_branch: Added $escaped_framework" - else - recent_change="- $escaped_branch: Added" - fi - - local substitutions=( - "s|\[PROJECT NAME\]|$project_name|" - "s|\[DATE\]|$current_date|" - "s|\[EXTRACTED FROM ALL PLAN.MD FILES\]|$tech_stack|" - "s|\[ACTUAL STRUCTURE FROM PLANS\]|$project_structure|g" - "s|\[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES\]|$commands|" - "s|\[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE\]|$language_conventions|" - "s|\[LAST 3 FEATURES AND WHAT THEY ADDED\]|$recent_change|" - ) - - for substitution in "${substitutions[@]}"; do - if ! sed -i.bak -e "$substitution" "$temp_file"; then - log_error "Failed to perform substitution: $substitution" - rm -f "$temp_file" "$temp_file.bak" - return 1 - fi - done - - # Convert literal \n sequences to actual newlines (portable — works on BSD + GNU) - awk '{gsub(/\\n/,"\n")}1' "$temp_file" > "$temp_file.tmp" - mv "$temp_file.tmp" "$temp_file" - - # Clean up backup files from sed -i.bak - rm -f "$temp_file.bak" - - # Prepend Cursor frontmatter for .mdc files so rules are auto-included - if [[ "$target_file" == *.mdc ]]; then - local frontmatter_file - frontmatter_file=$(mktemp) || return 1 - _CLEANUP_FILES+=("$frontmatter_file") - printf '%s\n' "---" "description: Project Development Guidelines" "globs: [\"**/*\"]" "alwaysApply: true" "---" "" > "$frontmatter_file" - cat "$temp_file" >> "$frontmatter_file" - mv "$frontmatter_file" "$temp_file" - fi - - return 0 -} - - - - -update_existing_agent_file() { - local target_file="$1" - local current_date="$2" - - log_info "Updating existing agent context file..." - - # Use a single temporary file for atomic update - local temp_file - temp_file=$(mktemp) || { - log_error "Failed to create temporary file" - return 1 - } - _CLEANUP_FILES+=("$temp_file") - - # Process the file in one pass - local tech_stack=$(format_technology_stack "$NEW_LANG" "$NEW_FRAMEWORK") - local new_tech_entries=() - local new_change_entry="" - - # Prepare new technology entries - if [[ -n "$tech_stack" ]] && ! grep -q "$tech_stack" "$target_file"; then - new_tech_entries+=("- $tech_stack ($CURRENT_BRANCH)") - fi - - if [[ -n "$NEW_DB" ]] && [[ "$NEW_DB" != "N/A" ]] && [[ "$NEW_DB" != "NEEDS CLARIFICATION" ]] && ! grep -q "$NEW_DB" "$target_file"; then - new_tech_entries+=("- $NEW_DB ($CURRENT_BRANCH)") - fi - - # Prepare new change entry - if [[ -n "$tech_stack" ]]; then - new_change_entry="- $CURRENT_BRANCH: Added $tech_stack" - elif [[ -n "$NEW_DB" ]] && [[ "$NEW_DB" != "N/A" ]] && [[ "$NEW_DB" != "NEEDS CLARIFICATION" ]]; then - new_change_entry="- $CURRENT_BRANCH: Added $NEW_DB" - fi - - # Check if sections exist in the file - local has_active_technologies=0 - local has_recent_changes=0 - - if grep -q "^## Active Technologies" "$target_file" 2>/dev/null; then - has_active_technologies=1 - fi - - if grep -q "^## Recent Changes" "$target_file" 2>/dev/null; then - has_recent_changes=1 - fi - - # Process file line by line - local in_tech_section=false - local in_changes_section=false - local tech_entries_added=false - local changes_entries_added=false - local existing_changes_count=0 - local file_ended=false - - while IFS= read -r line || [[ -n "$line" ]]; do - # Handle Active Technologies section - if [[ "$line" == "## Active Technologies" ]]; then - echo "$line" >> "$temp_file" - in_tech_section=true - continue - elif [[ $in_tech_section == true ]] && [[ "$line" =~ ^##[[:space:]] ]]; then - # Add new tech entries before closing the section - if [[ $tech_entries_added == false ]] && [[ ${#new_tech_entries[@]} -gt 0 ]]; then - printf '%s\n' "${new_tech_entries[@]}" >> "$temp_file" - tech_entries_added=true - fi - echo "$line" >> "$temp_file" - in_tech_section=false - continue - elif [[ $in_tech_section == true ]] && [[ -z "$line" ]]; then - # Add new tech entries before empty line in tech section - if [[ $tech_entries_added == false ]] && [[ ${#new_tech_entries[@]} -gt 0 ]]; then - printf '%s\n' "${new_tech_entries[@]}" >> "$temp_file" - tech_entries_added=true - fi - echo "$line" >> "$temp_file" - continue - fi - - # Handle Recent Changes section - if [[ "$line" == "## Recent Changes" ]]; then - echo "$line" >> "$temp_file" - # Add new change entry right after the heading - if [[ -n "$new_change_entry" ]]; then - echo "$new_change_entry" >> "$temp_file" - fi - in_changes_section=true - changes_entries_added=true - continue - elif [[ $in_changes_section == true ]] && [[ "$line" =~ ^##[[:space:]] ]]; then - echo "$line" >> "$temp_file" - in_changes_section=false - continue - elif [[ $in_changes_section == true ]] && [[ "$line" == "- "* ]]; then - # Keep only first 2 existing changes - if [[ $existing_changes_count -lt 2 ]]; then - echo "$line" >> "$temp_file" - ((existing_changes_count++)) - fi - continue - fi - - # Update timestamp - if [[ "$line" =~ (\*\*)?Last\ updated(\*\*)?:.*[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9] ]]; then - echo "$line" | sed "s/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]/$current_date/" >> "$temp_file" - else - echo "$line" >> "$temp_file" - fi - done < "$target_file" - - # Post-loop check: if we're still in the Active Technologies section and haven't added new entries - if [[ $in_tech_section == true ]] && [[ $tech_entries_added == false ]] && [[ ${#new_tech_entries[@]} -gt 0 ]]; then - printf '%s\n' "${new_tech_entries[@]}" >> "$temp_file" - tech_entries_added=true - fi - - # If sections don't exist, add them at the end of the file - if [[ $has_active_technologies -eq 0 ]] && [[ ${#new_tech_entries[@]} -gt 0 ]]; then - echo "" >> "$temp_file" - echo "## Active Technologies" >> "$temp_file" - printf '%s\n' "${new_tech_entries[@]}" >> "$temp_file" - tech_entries_added=true - fi - - if [[ $has_recent_changes -eq 0 ]] && [[ -n "$new_change_entry" ]]; then - echo "" >> "$temp_file" - echo "## Recent Changes" >> "$temp_file" - echo "$new_change_entry" >> "$temp_file" - changes_entries_added=true - fi - - # Ensure Cursor .mdc files have YAML frontmatter for auto-inclusion - if [[ "$target_file" == *.mdc ]]; then - if ! head -1 "$temp_file" | grep -q '^---'; then - local frontmatter_file - frontmatter_file=$(mktemp) || { rm -f "$temp_file"; return 1; } - _CLEANUP_FILES+=("$frontmatter_file") - printf '%s\n' "---" "description: Project Development Guidelines" "globs: [\"**/*\"]" "alwaysApply: true" "---" "" > "$frontmatter_file" - cat "$temp_file" >> "$frontmatter_file" - mv "$frontmatter_file" "$temp_file" - fi - fi - - # Move temp file to target atomically - if ! mv "$temp_file" "$target_file"; then - log_error "Failed to update target file" - rm -f "$temp_file" - return 1 - fi - - return 0 -} -#============================================================================== -# Main Agent File Update Function -#============================================================================== - -update_agent_file() { - local target_file="$1" - local agent_name="$2" - - if [[ -z "$target_file" ]] || [[ -z "$agent_name" ]]; then - log_error "update_agent_file requires target_file and agent_name parameters" - return 1 - fi - - log_info "Updating $agent_name context file: $target_file" - - local project_name - project_name=$(basename "$REPO_ROOT") - local current_date - current_date=$(date +%Y-%m-%d) - - # Create directory if it doesn't exist - local target_dir - target_dir=$(dirname "$target_file") - if [[ ! -d "$target_dir" ]]; then - if ! mkdir -p "$target_dir"; then - log_error "Failed to create directory: $target_dir" - return 1 - fi - fi - - if [[ ! -f "$target_file" ]]; then - # Create new file from template - local temp_file - temp_file=$(mktemp) || { - log_error "Failed to create temporary file" - return 1 - } - _CLEANUP_FILES+=("$temp_file") - - if create_new_agent_file "$target_file" "$temp_file" "$project_name" "$current_date"; then - if mv "$temp_file" "$target_file"; then - log_success "Created new $agent_name context file" - else - log_error "Failed to move temporary file to $target_file" - rm -f "$temp_file" - return 1 - fi - else - log_error "Failed to create new agent file" - rm -f "$temp_file" - return 1 - fi - else - # Update existing file - if [[ ! -r "$target_file" ]]; then - log_error "Cannot read existing file: $target_file" - return 1 - fi - - if [[ ! -w "$target_file" ]]; then - log_error "Cannot write to existing file: $target_file" - return 1 - fi - - if update_existing_agent_file "$target_file" "$current_date"; then - log_success "Updated existing $agent_name context file" - else - log_error "Failed to update existing agent file" - return 1 - fi - fi - - return 0 -} - -#============================================================================== -# Agent Selection and Processing -#============================================================================== - -update_specific_agent() { - local agent_type="$1" - - case "$agent_type" in - claude) - update_agent_file "$CLAUDE_FILE" "Claude Code" || return 1 - ;; - gemini) - update_agent_file "$GEMINI_FILE" "Gemini CLI" || return 1 - ;; - copilot) - update_agent_file "$COPILOT_FILE" "GitHub Copilot" || return 1 - ;; - cursor-agent) - update_agent_file "$CURSOR_FILE" "Cursor IDE" || return 1 - ;; - qwen) - update_agent_file "$QWEN_FILE" "Qwen Code" || return 1 - ;; - opencode) - update_agent_file "$AGENTS_FILE" "opencode" || return 1 - ;; - codex) - update_agent_file "$AGENTS_FILE" "Codex CLI" || return 1 - ;; - windsurf) - update_agent_file "$WINDSURF_FILE" "Windsurf" || return 1 - ;; - junie) - update_agent_file "$JUNIE_FILE" "Junie" || return 1 - ;; - kilocode) - update_agent_file "$KILOCODE_FILE" "Kilo Code" || return 1 - ;; - auggie) - update_agent_file "$AUGGIE_FILE" "Auggie CLI" || return 1 - ;; - roo) - update_agent_file "$ROO_FILE" "Roo Code" || return 1 - ;; - codebuddy) - update_agent_file "$CODEBUDDY_FILE" "CodeBuddy CLI" || return 1 - ;; - qodercli) - update_agent_file "$QODER_FILE" "Qoder CLI" || return 1 - ;; - amp) - update_agent_file "$AMP_FILE" "Amp" || return 1 - ;; - shai) - update_agent_file "$SHAI_FILE" "SHAI" || return 1 - ;; - tabnine) - update_agent_file "$TABNINE_FILE" "Tabnine CLI" || return 1 - ;; - kiro-cli) - update_agent_file "$KIRO_FILE" "Kiro CLI" || return 1 - ;; - agy) - update_agent_file "$AGY_FILE" "Antigravity" || return 1 - ;; - bob) - update_agent_file "$BOB_FILE" "IBM Bob" || return 1 - ;; - vibe) - update_agent_file "$VIBE_FILE" "Mistral Vibe" || return 1 - ;; - kimi) - update_agent_file "$KIMI_FILE" "Kimi Code" || return 1 - ;; - trae) - update_agent_file "$TRAE_FILE" "Trae" || return 1 - ;; - pi) - update_agent_file "$AGENTS_FILE" "Pi Coding Agent" || return 1 - ;; - iflow) - update_agent_file "$IFLOW_FILE" "iFlow CLI" || return 1 - ;; - forge) - update_agent_file "$AGENTS_FILE" "Forge" || return 1 - ;; - goose) - update_agent_file "$AGENTS_FILE" "Goose" || return 1 - ;; - generic) - log_info "Generic agent: no predefined context file. Use the agent-specific update script for your agent." - ;; - *) - log_error "Unknown agent type '$agent_type'" - log_error "Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|goose|generic" - exit 1 - ;; - esac -} - -# Helper: skip non-existent files and files already updated (dedup by -# realpath so that variables pointing to the same file — e.g. AMP_FILE, -# KIRO_FILE, BOB_FILE all resolving to AGENTS_FILE — are only written once). -# Uses a linear array instead of associative array for bash 3.2 compatibility. -# Note: defined at top level because bash 3.2 does not support true -# nested/local functions. _updated_paths, _found_agent, and _all_ok are -# initialised exclusively inside update_all_existing_agents so that -# sourcing this script has no side effects on the caller's environment. - -_update_if_new() { - local file="$1" name="$2" - [[ -f "$file" ]] || return 0 - local real_path - real_path=$(realpath "$file" 2>/dev/null || echo "$file") - local p - if [[ ${#_updated_paths[@]} -gt 0 ]]; then - for p in "${_updated_paths[@]}"; do - [[ "$p" == "$real_path" ]] && return 0 - done - fi - # Record the file as seen before attempting the update so that: - # (a) aliases pointing to the same path are not retried on failure - # (b) _found_agent reflects file existence, not update success - _updated_paths+=("$real_path") - _found_agent=true - update_agent_file "$file" "$name" -} - -update_all_existing_agents() { - _found_agent=false - _updated_paths=() - local _all_ok=true - - _update_if_new "$CLAUDE_FILE" "Claude Code" || _all_ok=false - _update_if_new "$GEMINI_FILE" "Gemini CLI" || _all_ok=false - _update_if_new "$COPILOT_FILE" "GitHub Copilot" || _all_ok=false - _update_if_new "$CURSOR_FILE" "Cursor IDE" || _all_ok=false - _update_if_new "$QWEN_FILE" "Qwen Code" || _all_ok=false - _update_if_new "$AGENTS_FILE" "Codex/opencode/Amp/Kiro/Bob/Pi/Forge/Goose" || _all_ok=false - _update_if_new "$WINDSURF_FILE" "Windsurf" || _all_ok=false - _update_if_new "$JUNIE_FILE" "Junie" || _all_ok=false - _update_if_new "$KILOCODE_FILE" "Kilo Code" || _all_ok=false - _update_if_new "$AUGGIE_FILE" "Auggie CLI" || _all_ok=false - _update_if_new "$ROO_FILE" "Roo Code" || _all_ok=false - _update_if_new "$CODEBUDDY_FILE" "CodeBuddy CLI" || _all_ok=false - _update_if_new "$SHAI_FILE" "SHAI" || _all_ok=false - _update_if_new "$TABNINE_FILE" "Tabnine CLI" || _all_ok=false - _update_if_new "$QODER_FILE" "Qoder CLI" || _all_ok=false - _update_if_new "$AGY_FILE" "Antigravity" || _all_ok=false - _update_if_new "$VIBE_FILE" "Mistral Vibe" || _all_ok=false - _update_if_new "$KIMI_FILE" "Kimi Code" || _all_ok=false - _update_if_new "$TRAE_FILE" "Trae" || _all_ok=false - _update_if_new "$IFLOW_FILE" "iFlow CLI" || _all_ok=false - - # If no agent files exist, create a default Claude file - if [[ "$_found_agent" == false ]]; then - log_info "No existing agent files found, creating default Claude file..." - update_agent_file "$CLAUDE_FILE" "Claude Code" || return 1 - fi - - [[ "$_all_ok" == true ]] -} -print_summary() { - echo - log_info "Summary of changes:" - - if [[ -n "$NEW_LANG" ]]; then - echo " - Added language: $NEW_LANG" - fi - - if [[ -n "$NEW_FRAMEWORK" ]]; then - echo " - Added framework: $NEW_FRAMEWORK" - fi - - if [[ -n "$NEW_DB" ]] && [[ "$NEW_DB" != "N/A" ]]; then - echo " - Added database: $NEW_DB" - fi - - echo - log_info "Usage: $0 [claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|goose|generic]" -} - -#============================================================================== -# Main Execution -#============================================================================== - -main() { - # Validate environment before proceeding - validate_environment - - log_info "=== Updating agent context files for feature $CURRENT_BRANCH ===" - - # Parse the plan file to extract project information - if ! parse_plan_data "$NEW_PLAN"; then - log_error "Failed to parse plan data" - exit 1 - fi - - # Process based on agent type argument - local success=true - - if [[ -z "$AGENT_TYPE" ]]; then - # No specific agent provided - update all existing agent files - log_info "No agent specified, updating all existing agent files..." - if ! update_all_existing_agents; then - success=false - fi - else - # Specific agent provided - update only that agent - log_info "Updating specific agent: $AGENT_TYPE" - if ! update_specific_agent "$AGENT_TYPE"; then - success=false - fi - fi - - # Print summary - print_summary - - if [[ "$success" == true ]]; then - log_success "Agent context update completed successfully" - exit 0 - else - log_error "Agent context update completed with errors" - exit 1 - fi -} - -# Execute main function if script is run directly -if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then - main "$@" -fi diff --git a/scripts/powershell/update-agent-context.ps1 b/scripts/powershell/update-agent-context.ps1 deleted file mode 100644 index 3ee45d383c..0000000000 --- a/scripts/powershell/update-agent-context.ps1 +++ /dev/null @@ -1,515 +0,0 @@ -#!/usr/bin/env pwsh -<#! -.SYNOPSIS -Update agent context files with information from plan.md (PowerShell version) - -.DESCRIPTION -Mirrors the behavior of scripts/bash/update-agent-context.sh: - 1. Environment Validation - 2. Plan Data Extraction - 3. Agent File Management (create from template or update existing) - 4. Content Generation (technology stack, recent changes, timestamp) - 5. Multi-Agent Support (claude, gemini, copilot, cursor-agent, qwen, opencode, codex, windsurf, junie, kilocode, auggie, roo, codebuddy, amp, shai, tabnine, kiro-cli, agy, bob, vibe, qodercli, kimi, trae, pi, iflow, forge, goose, generic) - -.PARAMETER AgentType -Optional agent key to update a single agent. If omitted, updates all existing agent files (creating a default Claude file if none exist). - -.EXAMPLE -./update-agent-context.ps1 -AgentType claude - -.EXAMPLE -./update-agent-context.ps1 # Updates all existing agent files - -.NOTES -Relies on common helper functions in common.ps1 -#> -param( - [Parameter(Position=0)] - [ValidateSet('claude','gemini','copilot','cursor-agent','qwen','opencode','codex','windsurf','junie','kilocode','auggie','roo','codebuddy','amp','shai','tabnine','kiro-cli','agy','bob','vibe','qodercli','kimi','trae','pi','iflow','forge','goose','generic')] - [string]$AgentType -) - -$ErrorActionPreference = 'Stop' - -# Import common helpers -$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path -. (Join-Path $ScriptDir 'common.ps1') - -# Acquire environment paths -$envData = Get-FeaturePathsEnv -$REPO_ROOT = $envData.REPO_ROOT -$CURRENT_BRANCH = $envData.CURRENT_BRANCH -$HAS_GIT = $envData.HAS_GIT -$IMPL_PLAN = $envData.IMPL_PLAN -$NEW_PLAN = $IMPL_PLAN - -# Agent file paths -$CLAUDE_FILE = Join-Path $REPO_ROOT 'CLAUDE.md' -$GEMINI_FILE = Join-Path $REPO_ROOT 'GEMINI.md' -$COPILOT_FILE = Join-Path $REPO_ROOT '.github/copilot-instructions.md' -$CURSOR_FILE = Join-Path $REPO_ROOT '.cursor/rules/specify-rules.mdc' -$QWEN_FILE = Join-Path $REPO_ROOT 'QWEN.md' -$AGENTS_FILE = Join-Path $REPO_ROOT 'AGENTS.md' -$WINDSURF_FILE = Join-Path $REPO_ROOT '.windsurf/rules/specify-rules.md' -$JUNIE_FILE = Join-Path $REPO_ROOT '.junie/AGENTS.md' -$KILOCODE_FILE = Join-Path $REPO_ROOT '.kilocode/rules/specify-rules.md' -$AUGGIE_FILE = Join-Path $REPO_ROOT '.augment/rules/specify-rules.md' -$ROO_FILE = Join-Path $REPO_ROOT '.roo/rules/specify-rules.md' -$CODEBUDDY_FILE = Join-Path $REPO_ROOT 'CODEBUDDY.md' -$QODER_FILE = Join-Path $REPO_ROOT 'QODER.md' -$AMP_FILE = Join-Path $REPO_ROOT 'AGENTS.md' -$SHAI_FILE = Join-Path $REPO_ROOT 'SHAI.md' -$TABNINE_FILE = Join-Path $REPO_ROOT 'TABNINE.md' -$KIRO_FILE = Join-Path $REPO_ROOT 'AGENTS.md' -$AGY_FILE = Join-Path $REPO_ROOT '.agent/rules/specify-rules.md' -$BOB_FILE = Join-Path $REPO_ROOT 'AGENTS.md' -$VIBE_FILE = Join-Path $REPO_ROOT '.vibe/agents/specify-agents.md' -$KIMI_FILE = Join-Path $REPO_ROOT 'KIMI.md' -$TRAE_FILE = Join-Path $REPO_ROOT '.trae/rules/project_rules.md' -$IFLOW_FILE = Join-Path $REPO_ROOT 'IFLOW.md' -$FORGE_FILE = Join-Path $REPO_ROOT 'AGENTS.md' -$GOOSE_FILE = Join-Path $REPO_ROOT 'AGENTS.md' - -$TEMPLATE_FILE = Join-Path $REPO_ROOT '.specify/templates/agent-file-template.md' - -# Parsed plan data placeholders -$script:NEW_LANG = '' -$script:NEW_FRAMEWORK = '' -$script:NEW_DB = '' -$script:NEW_PROJECT_TYPE = '' - -function Write-Info { - param( - [Parameter(Mandatory=$true)] - [string]$Message - ) - Write-Host "INFO: $Message" -} - -function Write-Success { - param( - [Parameter(Mandatory=$true)] - [string]$Message - ) - Write-Host "$([char]0x2713) $Message" -} - -function Write-WarningMsg { - param( - [Parameter(Mandatory=$true)] - [string]$Message - ) - Write-Warning $Message -} - -function Write-Err { - param( - [Parameter(Mandatory=$true)] - [string]$Message - ) - Write-Host "ERROR: $Message" -ForegroundColor Red -} - -function Validate-Environment { - if (-not $CURRENT_BRANCH) { - Write-Err 'Unable to determine current feature' - if ($HAS_GIT) { Write-Info "Make sure you're on a feature branch" } else { Write-Info 'Set SPECIFY_FEATURE environment variable or create a feature first' } - exit 1 - } - if (-not (Test-Path $NEW_PLAN)) { - Write-Err "No plan.md found at $NEW_PLAN" - Write-Info 'Ensure you are working on a feature with a corresponding spec directory' - if (-not $HAS_GIT) { Write-Info 'Use: $env:SPECIFY_FEATURE=your-feature-name or create a new feature first' } - exit 1 - } - if (-not (Test-Path $TEMPLATE_FILE)) { - Write-Err "Template file not found at $TEMPLATE_FILE" - Write-Info 'Run specify init to scaffold .specify/templates, or add agent-file-template.md there.' - exit 1 - } -} - -function Extract-PlanField { - param( - [Parameter(Mandatory=$true)] - [string]$FieldPattern, - [Parameter(Mandatory=$true)] - [string]$PlanFile - ) - if (-not (Test-Path $PlanFile)) { return '' } - # Lines like **Language/Version**: Python 3.12 - $regex = "^\*\*$([Regex]::Escape($FieldPattern))\*\*: (.+)$" - Get-Content -LiteralPath $PlanFile -Encoding utf8 | ForEach-Object { - if ($_ -match $regex) { - $val = $Matches[1].Trim() - if ($val -notin @('NEEDS CLARIFICATION','N/A')) { return $val } - } - } | Select-Object -First 1 -} - -function Parse-PlanData { - param( - [Parameter(Mandatory=$true)] - [string]$PlanFile - ) - if (-not (Test-Path $PlanFile)) { Write-Err "Plan file not found: $PlanFile"; return $false } - Write-Info "Parsing plan data from $PlanFile" - $script:NEW_LANG = Extract-PlanField -FieldPattern 'Language/Version' -PlanFile $PlanFile - $script:NEW_FRAMEWORK = Extract-PlanField -FieldPattern 'Primary Dependencies' -PlanFile $PlanFile - $script:NEW_DB = Extract-PlanField -FieldPattern 'Storage' -PlanFile $PlanFile - $script:NEW_PROJECT_TYPE = Extract-PlanField -FieldPattern 'Project Type' -PlanFile $PlanFile - - if ($NEW_LANG) { Write-Info "Found language: $NEW_LANG" } else { Write-WarningMsg 'No language information found in plan' } - if ($NEW_FRAMEWORK) { Write-Info "Found framework: $NEW_FRAMEWORK" } - if ($NEW_DB -and $NEW_DB -ne 'N/A') { Write-Info "Found database: $NEW_DB" } - if ($NEW_PROJECT_TYPE) { Write-Info "Found project type: $NEW_PROJECT_TYPE" } - return $true -} - -function Format-TechnologyStack { - param( - [Parameter(Mandatory=$false)] - [string]$Lang, - [Parameter(Mandatory=$false)] - [string]$Framework - ) - $parts = @() - if ($Lang -and $Lang -ne 'NEEDS CLARIFICATION') { $parts += $Lang } - if ($Framework -and $Framework -notin @('NEEDS CLARIFICATION','N/A')) { $parts += $Framework } - if (-not $parts) { return '' } - return ($parts -join ' + ') -} - -function Get-ProjectStructure { - param( - [Parameter(Mandatory=$false)] - [string]$ProjectType - ) - if ($ProjectType -match 'web') { return "backend/`nfrontend/`ntests/" } else { return "src/`ntests/" } -} - -function Get-CommandsForLanguage { - param( - [Parameter(Mandatory=$false)] - [string]$Lang - ) - switch -Regex ($Lang) { - 'Python' { return "cd src; pytest; ruff check ." } - 'Rust' { return "cargo test; cargo clippy" } - 'JavaScript|TypeScript' { return "npm test; npm run lint" } - default { return "# Add commands for $Lang" } - } -} - -function Get-LanguageConventions { - param( - [Parameter(Mandatory=$false)] - [string]$Lang - ) - if ($Lang) { "${Lang}: Follow standard conventions" } else { 'General: Follow standard conventions' } -} - -function New-AgentFile { - param( - [Parameter(Mandatory=$true)] - [string]$TargetFile, - [Parameter(Mandatory=$true)] - [string]$ProjectName, - [Parameter(Mandatory=$true)] - [datetime]$Date - ) - if (-not (Test-Path $TEMPLATE_FILE)) { Write-Err "Template not found at $TEMPLATE_FILE"; return $false } - $temp = New-TemporaryFile - Copy-Item -LiteralPath $TEMPLATE_FILE -Destination $temp -Force - - $projectStructure = Get-ProjectStructure -ProjectType $NEW_PROJECT_TYPE - $commands = Get-CommandsForLanguage -Lang $NEW_LANG - $languageConventions = Get-LanguageConventions -Lang $NEW_LANG - - $escaped_lang = $NEW_LANG - $escaped_framework = $NEW_FRAMEWORK - $escaped_branch = $CURRENT_BRANCH - - $content = Get-Content -LiteralPath $temp -Raw -Encoding utf8 - $content = $content -replace '\[PROJECT NAME\]',$ProjectName - $content = $content -replace '\[DATE\]',$Date.ToString('yyyy-MM-dd') - - # Build the technology stack string safely - $techStackForTemplate = "" - if ($escaped_lang -and $escaped_framework) { - $techStackForTemplate = "- $escaped_lang + $escaped_framework ($escaped_branch)" - } elseif ($escaped_lang) { - $techStackForTemplate = "- $escaped_lang ($escaped_branch)" - } elseif ($escaped_framework) { - $techStackForTemplate = "- $escaped_framework ($escaped_branch)" - } - - $content = $content -replace '\[EXTRACTED FROM ALL PLAN.MD FILES\]',$techStackForTemplate - # For project structure we manually embed (keep newlines) - $escapedStructure = [Regex]::Escape($projectStructure) - $content = $content -replace '\[ACTUAL STRUCTURE FROM PLANS\]',$escapedStructure - # Replace escaped newlines placeholder after all replacements - $content = $content -replace '\[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES\]',$commands - $content = $content -replace '\[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE\]',$languageConventions - - # Build the recent changes string safely - $recentChangesForTemplate = "" - if ($escaped_lang -and $escaped_framework) { - $recentChangesForTemplate = "- ${escaped_branch}: Added ${escaped_lang} + ${escaped_framework}" - } elseif ($escaped_lang) { - $recentChangesForTemplate = "- ${escaped_branch}: Added ${escaped_lang}" - } elseif ($escaped_framework) { - $recentChangesForTemplate = "- ${escaped_branch}: Added ${escaped_framework}" - } - - $content = $content -replace '\[LAST 3 FEATURES AND WHAT THEY ADDED\]',$recentChangesForTemplate - # Convert literal \n sequences introduced by Escape to real newlines - $content = $content -replace '\\n',[Environment]::NewLine - - # Prepend Cursor frontmatter for .mdc files so rules are auto-included - if ($TargetFile -match '\.mdc$') { - $frontmatter = @('---','description: Project Development Guidelines','globs: ["**/*"]','alwaysApply: true','---','') -join [Environment]::NewLine - $content = $frontmatter + $content - } - - $parent = Split-Path -Parent $TargetFile - if (-not (Test-Path $parent)) { New-Item -ItemType Directory -Path $parent | Out-Null } - Set-Content -LiteralPath $TargetFile -Value $content -NoNewline -Encoding utf8 - Remove-Item $temp -Force - return $true -} - -function Update-ExistingAgentFile { - param( - [Parameter(Mandatory=$true)] - [string]$TargetFile, - [Parameter(Mandatory=$true)] - [datetime]$Date - ) - if (-not (Test-Path $TargetFile)) { return (New-AgentFile -TargetFile $TargetFile -ProjectName (Split-Path $REPO_ROOT -Leaf) -Date $Date) } - - $techStack = Format-TechnologyStack -Lang $NEW_LANG -Framework $NEW_FRAMEWORK - $newTechEntries = @() - if ($techStack) { - $escapedTechStack = [Regex]::Escape($techStack) - if (-not (Select-String -Pattern $escapedTechStack -Path $TargetFile -Quiet)) { - $newTechEntries += "- $techStack ($CURRENT_BRANCH)" - } - } - if ($NEW_DB -and $NEW_DB -notin @('N/A','NEEDS CLARIFICATION')) { - $escapedDB = [Regex]::Escape($NEW_DB) - if (-not (Select-String -Pattern $escapedDB -Path $TargetFile -Quiet)) { - $newTechEntries += "- $NEW_DB ($CURRENT_BRANCH)" - } - } - $newChangeEntry = '' - if ($techStack) { $newChangeEntry = "- ${CURRENT_BRANCH}: Added ${techStack}" } - elseif ($NEW_DB -and $NEW_DB -notin @('N/A','NEEDS CLARIFICATION')) { $newChangeEntry = "- ${CURRENT_BRANCH}: Added ${NEW_DB}" } - - $lines = Get-Content -LiteralPath $TargetFile -Encoding utf8 - $output = New-Object System.Collections.Generic.List[string] - $inTech = $false; $inChanges = $false; $techAdded = $false; $changeAdded = $false; $existingChanges = 0 - - for ($i=0; $i -lt $lines.Count; $i++) { - $line = $lines[$i] - if ($line -eq '## Active Technologies') { - $output.Add($line) - $inTech = $true - continue - } - if ($inTech -and $line -match '^##\s') { - if (-not $techAdded -and $newTechEntries.Count -gt 0) { $newTechEntries | ForEach-Object { $output.Add($_) }; $techAdded = $true } - $output.Add($line); $inTech = $false; continue - } - if ($inTech -and [string]::IsNullOrWhiteSpace($line)) { - if (-not $techAdded -and $newTechEntries.Count -gt 0) { $newTechEntries | ForEach-Object { $output.Add($_) }; $techAdded = $true } - $output.Add($line); continue - } - if ($line -eq '## Recent Changes') { - $output.Add($line) - if ($newChangeEntry) { $output.Add($newChangeEntry); $changeAdded = $true } - $inChanges = $true - continue - } - if ($inChanges -and $line -match '^##\s') { $output.Add($line); $inChanges = $false; continue } - if ($inChanges -and $line -match '^- ') { - if ($existingChanges -lt 2) { $output.Add($line); $existingChanges++ } - continue - } - if ($line -match '(\*\*)?Last updated(\*\*)?: .*\d{4}-\d{2}-\d{2}') { - $output.Add(($line -replace '\d{4}-\d{2}-\d{2}',$Date.ToString('yyyy-MM-dd'))) - continue - } - $output.Add($line) - } - - # Post-loop check: if we're still in the Active Technologies section and haven't added new entries - if ($inTech -and -not $techAdded -and $newTechEntries.Count -gt 0) { - $newTechEntries | ForEach-Object { $output.Add($_) } - } - - # Ensure Cursor .mdc files have YAML frontmatter for auto-inclusion - if ($TargetFile -match '\.mdc$' -and $output.Count -gt 0 -and $output[0] -ne '---') { - $frontmatter = @('---','description: Project Development Guidelines','globs: ["**/*"]','alwaysApply: true','---','') - $output.InsertRange(0, $frontmatter) - } - - Set-Content -LiteralPath $TargetFile -Value ($output -join [Environment]::NewLine) -Encoding utf8 - return $true -} - -function Update-AgentFile { - param( - [Parameter(Mandatory=$true)] - [string]$TargetFile, - [Parameter(Mandatory=$true)] - [string]$AgentName - ) - if (-not $TargetFile -or -not $AgentName) { Write-Err 'Update-AgentFile requires TargetFile and AgentName'; return $false } - Write-Info "Updating $AgentName context file: $TargetFile" - $projectName = Split-Path $REPO_ROOT -Leaf - $date = Get-Date - - $dir = Split-Path -Parent $TargetFile - if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir | Out-Null } - - if (-not (Test-Path $TargetFile)) { - if (New-AgentFile -TargetFile $TargetFile -ProjectName $projectName -Date $date) { Write-Success "Created new $AgentName context file" } else { Write-Err 'Failed to create new agent file'; return $false } - } else { - try { - if (Update-ExistingAgentFile -TargetFile $TargetFile -Date $date) { Write-Success "Updated existing $AgentName context file" } else { Write-Err 'Failed to update agent file'; return $false } - } catch { - Write-Err "Cannot access or update existing file: $TargetFile. $_" - return $false - } - } - return $true -} - -function Update-SpecificAgent { - param( - [Parameter(Mandatory=$true)] - [string]$Type - ) - switch ($Type) { - 'claude' { Update-AgentFile -TargetFile $CLAUDE_FILE -AgentName 'Claude Code' } - 'gemini' { Update-AgentFile -TargetFile $GEMINI_FILE -AgentName 'Gemini CLI' } - 'copilot' { Update-AgentFile -TargetFile $COPILOT_FILE -AgentName 'GitHub Copilot' } - 'cursor-agent' { Update-AgentFile -TargetFile $CURSOR_FILE -AgentName 'Cursor IDE' } - 'qwen' { Update-AgentFile -TargetFile $QWEN_FILE -AgentName 'Qwen Code' } - 'opencode' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'opencode' } - 'codex' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Codex CLI' } - 'windsurf' { Update-AgentFile -TargetFile $WINDSURF_FILE -AgentName 'Windsurf' } - 'junie' { Update-AgentFile -TargetFile $JUNIE_FILE -AgentName 'Junie' } - 'kilocode' { Update-AgentFile -TargetFile $KILOCODE_FILE -AgentName 'Kilo Code' } - 'auggie' { Update-AgentFile -TargetFile $AUGGIE_FILE -AgentName 'Auggie CLI' } - 'roo' { Update-AgentFile -TargetFile $ROO_FILE -AgentName 'Roo Code' } - 'codebuddy' { Update-AgentFile -TargetFile $CODEBUDDY_FILE -AgentName 'CodeBuddy CLI' } - 'qodercli' { Update-AgentFile -TargetFile $QODER_FILE -AgentName 'Qoder CLI' } - 'amp' { Update-AgentFile -TargetFile $AMP_FILE -AgentName 'Amp' } - 'shai' { Update-AgentFile -TargetFile $SHAI_FILE -AgentName 'SHAI' } - 'tabnine' { Update-AgentFile -TargetFile $TABNINE_FILE -AgentName 'Tabnine CLI' } - 'kiro-cli' { Update-AgentFile -TargetFile $KIRO_FILE -AgentName 'Kiro CLI' } - 'agy' { Update-AgentFile -TargetFile $AGY_FILE -AgentName 'Antigravity' } - 'bob' { Update-AgentFile -TargetFile $BOB_FILE -AgentName 'IBM Bob' } - 'vibe' { Update-AgentFile -TargetFile $VIBE_FILE -AgentName 'Mistral Vibe' } - 'kimi' { Update-AgentFile -TargetFile $KIMI_FILE -AgentName 'Kimi Code' } - 'trae' { Update-AgentFile -TargetFile $TRAE_FILE -AgentName 'Trae' } - 'pi' { Update-AgentFile -TargetFile $AGENTS_FILE -AgentName 'Pi Coding Agent' } - 'iflow' { Update-AgentFile -TargetFile $IFLOW_FILE -AgentName 'iFlow CLI' } - 'forge' { Update-AgentFile -TargetFile $FORGE_FILE -AgentName 'Forge' } - 'goose' { Update-AgentFile -TargetFile $GOOSE_FILE -AgentName 'Goose' } - 'generic' { Write-Info 'Generic agent: no predefined context file. Use the agent-specific update script for your agent.' } - default { Write-Err "Unknown agent type '$Type'"; Write-Err 'Expected: claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|goose|generic'; return $false } - } -} - -function Update-AllExistingAgents { - $found = $false - $ok = $true - $updatedPaths = @() - - # Helper function to update only if file exists and hasn't been updated yet - function Update-IfNew { - param( - [Parameter(Mandatory=$true)] - [string]$FilePath, - [Parameter(Mandatory=$true)] - [string]$AgentName - ) - - if (-not (Test-Path $FilePath)) { return $true } - - # Get the real path to detect duplicates (e.g., AMP_FILE, KIRO_FILE, BOB_FILE all point to AGENTS.md) - $realPath = (Get-Item -LiteralPath $FilePath).FullName - - # Check if we've already updated this file - if ($updatedPaths -contains $realPath) { - return $true - } - - # Record the file as seen before attempting the update - # Use parent scope (1) to modify Update-AllExistingAgents' local variables - Set-Variable -Name updatedPaths -Value ($updatedPaths + $realPath) -Scope 1 - Set-Variable -Name found -Value $true -Scope 1 - - # Perform the update - return (Update-AgentFile -TargetFile $FilePath -AgentName $AgentName) - } - - if (-not (Update-IfNew -FilePath $CLAUDE_FILE -AgentName 'Claude Code')) { $ok = $false } - if (-not (Update-IfNew -FilePath $GEMINI_FILE -AgentName 'Gemini CLI')) { $ok = $false } - if (-not (Update-IfNew -FilePath $COPILOT_FILE -AgentName 'GitHub Copilot')) { $ok = $false } - if (-not (Update-IfNew -FilePath $CURSOR_FILE -AgentName 'Cursor IDE')) { $ok = $false } - if (-not (Update-IfNew -FilePath $QWEN_FILE -AgentName 'Qwen Code')) { $ok = $false } - if (-not (Update-IfNew -FilePath $AGENTS_FILE -AgentName 'Codex/opencode/Amp/Kiro/Bob/Pi/Forge/Goose')) { $ok = $false } - if (-not (Update-IfNew -FilePath $WINDSURF_FILE -AgentName 'Windsurf')) { $ok = $false } - if (-not (Update-IfNew -FilePath $JUNIE_FILE -AgentName 'Junie')) { $ok = $false } - if (-not (Update-IfNew -FilePath $KILOCODE_FILE -AgentName 'Kilo Code')) { $ok = $false } - if (-not (Update-IfNew -FilePath $AUGGIE_FILE -AgentName 'Auggie CLI')) { $ok = $false } - if (-not (Update-IfNew -FilePath $ROO_FILE -AgentName 'Roo Code')) { $ok = $false } - if (-not (Update-IfNew -FilePath $CODEBUDDY_FILE -AgentName 'CodeBuddy CLI')) { $ok = $false } - if (-not (Update-IfNew -FilePath $QODER_FILE -AgentName 'Qoder CLI')) { $ok = $false } - if (-not (Update-IfNew -FilePath $SHAI_FILE -AgentName 'SHAI')) { $ok = $false } - if (-not (Update-IfNew -FilePath $TABNINE_FILE -AgentName 'Tabnine CLI')) { $ok = $false } - if (-not (Update-IfNew -FilePath $AGY_FILE -AgentName 'Antigravity')) { $ok = $false } - if (-not (Update-IfNew -FilePath $VIBE_FILE -AgentName 'Mistral Vibe')) { $ok = $false } - if (-not (Update-IfNew -FilePath $KIMI_FILE -AgentName 'Kimi Code')) { $ok = $false } - if (-not (Update-IfNew -FilePath $TRAE_FILE -AgentName 'Trae')) { $ok = $false } - if (-not (Update-IfNew -FilePath $IFLOW_FILE -AgentName 'iFlow CLI')) { $ok = $false } - - if (-not $found) { - Write-Info 'No existing agent files found, creating default Claude file...' - if (-not (Update-AgentFile -TargetFile $CLAUDE_FILE -AgentName 'Claude Code')) { $ok = $false } - } - return $ok -} - -function Print-Summary { - Write-Host '' - Write-Info 'Summary of changes:' - if ($NEW_LANG) { Write-Host " - Added language: $NEW_LANG" } - if ($NEW_FRAMEWORK) { Write-Host " - Added framework: $NEW_FRAMEWORK" } - if ($NEW_DB -and $NEW_DB -ne 'N/A') { Write-Host " - Added database: $NEW_DB" } - Write-Host '' - Write-Info 'Usage: ./update-agent-context.ps1 [-AgentType claude|gemini|copilot|cursor-agent|qwen|opencode|codex|windsurf|junie|kilocode|auggie|roo|codebuddy|amp|shai|tabnine|kiro-cli|agy|bob|vibe|qodercli|kimi|trae|pi|iflow|forge|goose|generic]' -} - -function Main { - Validate-Environment - Write-Info "=== Updating agent context files for feature $CURRENT_BRANCH ===" - if (-not (Parse-PlanData -PlanFile $NEW_PLAN)) { Write-Err 'Failed to parse plan data'; exit 1 } - $success = $true - if ($AgentType) { - Write-Info "Updating specific agent: $AgentType" - if (-not (Update-SpecificAgent -Type $AgentType)) { $success = $false } - } - else { - Write-Info 'No agent specified, updating all existing agent files...' - if (-not (Update-AllExistingAgents)) { $success = $false } - } - Print-Summary - if ($success) { Write-Success 'Agent context update completed successfully'; exit 0 } else { Write-Err 'Agent context update completed with errors'; exit 1 } -} - -Main diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 0608e7a8ac..50badd830d 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1261,15 +1261,11 @@ def init( manifest.save() # Write .specify/integration.json - script_ext = "sh" if selected_script == "sh" else "ps1" integration_json = project_path / ".specify" / "integration.json" integration_json.parent.mkdir(parents=True, exist_ok=True) integration_json.write_text(json.dumps({ "integration": resolved_integration.key, "version": get_speckit_version(), - "scripts": { - "update-context": f".specify/integrations/{resolved_integration.key}/scripts/update-context.{script_ext}", - }, }, indent=2) + "\n", encoding="utf-8") tracker.complete("integration", resolved_integration.config.get("name", resolved_integration.key)) @@ -1373,6 +1369,7 @@ def init( "ai": selected_ai, "integration": resolved_integration.key, "branch_numbering": branch_numbering or "sequential", + "context_file": resolved_integration.context_file, "here": here, "preset": preset, "script": selected_script, @@ -1740,15 +1737,11 @@ def _write_integration_json( script_type: str, ) -> None: """Write ``.specify/integration.json`` for *integration_key*.""" - script_ext = "sh" if script_type == "sh" else "ps1" dest = project_root / INTEGRATION_JSON dest.parent.mkdir(parents=True, exist_ok=True) dest.write_text(json.dumps({ "integration": integration_key, "version": get_speckit_version(), - "scripts": { - "update-context": f".specify/integrations/{integration_key}/scripts/update-context.{script_ext}", - }, }, indent=2) + "\n", encoding="utf-8") @@ -2013,6 +2006,7 @@ def _update_init_options_for_integration( opts = load_init_options(project_root) opts["integration"] = integration.key opts["ai"] = integration.key + opts["context_file"] = integration.context_file if script_type: opts["script"] = script_type if isinstance(integration, SkillsIntegration): @@ -2064,6 +2058,7 @@ def integration_uninstall( opts.pop("integration", None) opts.pop("ai", None) opts.pop("ai_skills", None) + opts.pop("context_file", None) save_init_options(project_root, opts) raise typer.Exit(0) @@ -2090,6 +2085,7 @@ def integration_uninstall( opts.pop("integration", None) opts.pop("ai", None) opts.pop("ai_skills", None) + opts.pop("context_file", None) save_init_options(project_root, opts) name = (integration.config or {}).get("name", key) if integration else key diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 32fc6cdbf0..31dc2e99f0 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -110,9 +110,9 @@ def _adjust_script_paths(self, frontmatter: dict) -> dict: """Normalize script paths in frontmatter to generated project locations. Rewrites known repo-relative and top-level script paths under the - `scripts` and `agent_scripts` keys (for example `../../scripts/`, - `../../templates/`, `../../memory/`, `scripts/`, `templates/`, and - `memory/`) to the `.specify/...` paths used in generated projects. + ``scripts`` key (for example ``../../scripts/``, + ``../../templates/``, ``../../memory/``, ``scripts/``, ``templates/``, and + ``memory/``) to the ``.specify/...`` paths used in generated projects. Args: frontmatter: Frontmatter dictionary @@ -122,11 +122,8 @@ def _adjust_script_paths(self, frontmatter: dict) -> dict: """ frontmatter = deepcopy(frontmatter) - for script_key in ("scripts", "agent_scripts"): - scripts = frontmatter.get(script_key) - if not isinstance(scripts, dict): - continue - + scripts = frontmatter.get("scripts") + if isinstance(scripts, dict): for key, script_path in scripts.items(): if isinstance(script_path, str): scripts[key] = self.rewrite_project_relative_paths(script_path) @@ -333,11 +330,8 @@ def resolve_skill_placeholders( frontmatter = {} scripts = frontmatter.get("scripts", {}) or {} - agent_scripts = frontmatter.get("agent_scripts", {}) or {} if not isinstance(scripts, dict): scripts = {} - if not isinstance(agent_scripts, dict): - agent_scripts = {} init_opts = load_init_options(project_root) if not isinstance(init_opts, dict): @@ -351,17 +345,14 @@ def resolve_skill_placeholders( ) secondary_variant = "sh" if default_variant == "ps" else "ps" - if default_variant in scripts or default_variant in agent_scripts: + if default_variant in scripts: fallback_order.append(default_variant) - if secondary_variant in scripts or secondary_variant in agent_scripts: + if secondary_variant in scripts: fallback_order.append(secondary_variant) for key in scripts: if key not in fallback_order: fallback_order.append(key) - for key in agent_scripts: - if key not in fallback_order: - fallback_order.append(key) script_variant = fallback_order[0] if fallback_order else None @@ -370,13 +361,6 @@ def resolve_skill_placeholders( script_command = script_command.replace("{ARGS}", "$ARGUMENTS") body = body.replace("{SCRIPT}", script_command) - agent_script_command = ( - agent_scripts.get(script_variant) if script_variant else None - ) - if agent_script_command: - agent_script_command = agent_script_command.replace("{ARGS}", "$ARGUMENTS") - body = body.replace("{AGENT_SCRIPT}", agent_script_command) - body = body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", agent_name) return CommandRegistrar.rewrite_project_relative_paths(body) diff --git a/src/specify_cli/integrations/agy/scripts/update-context.ps1 b/src/specify_cli/integrations/agy/scripts/update-context.ps1 deleted file mode 100644 index 9eeb461657..0000000000 --- a/src/specify_cli/integrations/agy/scripts/update-context.ps1 +++ /dev/null @@ -1,17 +0,0 @@ -# update-context.ps1 — Antigravity (agy) integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. - -$ErrorActionPreference = 'Stop' - -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType agy diff --git a/src/specify_cli/integrations/agy/scripts/update-context.sh b/src/specify_cli/integrations/agy/scripts/update-context.sh deleted file mode 100755 index d7303f6197..0000000000 --- a/src/specify_cli/integrations/agy/scripts/update-context.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Antigravity (agy) integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. - -set -euo pipefail - -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" agy diff --git a/src/specify_cli/integrations/amp/scripts/update-context.ps1 b/src/specify_cli/integrations/amp/scripts/update-context.ps1 deleted file mode 100644 index c217b99f9a..0000000000 --- a/src/specify_cli/integrations/amp/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Amp integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType amp diff --git a/src/specify_cli/integrations/amp/scripts/update-context.sh b/src/specify_cli/integrations/amp/scripts/update-context.sh deleted file mode 100755 index 56cbf6e787..0000000000 --- a/src/specify_cli/integrations/amp/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Amp integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" amp diff --git a/src/specify_cli/integrations/auggie/scripts/update-context.ps1 b/src/specify_cli/integrations/auggie/scripts/update-context.ps1 deleted file mode 100644 index 49e7e6b5f3..0000000000 --- a/src/specify_cli/integrations/auggie/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Auggie CLI integration: create/update .augment/rules/specify-rules.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType auggie diff --git a/src/specify_cli/integrations/auggie/scripts/update-context.sh b/src/specify_cli/integrations/auggie/scripts/update-context.sh deleted file mode 100755 index 4cf80bba2b..0000000000 --- a/src/specify_cli/integrations/auggie/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Auggie CLI integration: create/update .augment/rules/specify-rules.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" auggie diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 2c01e25b0e..86cf08af7e 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -84,6 +84,11 @@ class IntegrationBase(ABC): context_file: str | None = None """Relative path to the agent context file (e.g. ``CLAUDE.md``).""" + # -- Markers for managed context section ------------------------------ + + CONTEXT_MARKER_START = "" + CONTEXT_MARKER_END = "" + # -- Public API ------------------------------------------------------- @classmethod @@ -380,22 +385,130 @@ def install_scripts( return created + # -- Agent context file management ------------------------------------ + + @staticmethod + def _build_context_section(plan_path: str = "") -> str: + """Build the content for the managed section between markers. + + *plan_path* is the project-relative path to the current plan + (e.g. ``".specify/plans/plan.md"``). When empty, the section + contains only the generic directive without a concrete path. + """ + lines = [ + "For additional context about technologies to be used, project structure,", + "shell commands and other important information read the current plan", + ] + if plan_path: + lines.append(f"at {plan_path}") + return "\n".join(lines) + + def upsert_context_section( + self, + project_root: Path, + plan_path: str = "", + ) -> Path | None: + """Create or update the managed section in the agent context file. + + If the context file does not exist it is created with just the + managed section. If it exists, the content between + ```` and ```` markers + is replaced (or appended when no markers are found). + + Returns the path to the context file, or ``None`` when + ``context_file`` is not set. + """ + if not self.context_file: + return None + + ctx_path = project_root / self.context_file + section = ( + f"{self.CONTEXT_MARKER_START}\n" + f"{self._build_context_section(plan_path)}\n" + f"{self.CONTEXT_MARKER_END}\n" + ) + + if ctx_path.exists(): + content = ctx_path.read_text(encoding="utf-8") + start_idx = content.find(self.CONTEXT_MARKER_START) + end_idx = content.find(self.CONTEXT_MARKER_END) + + if start_idx != -1 and end_idx != -1: + # Replace existing section (include the end marker + newline) + end_of_marker = end_idx + len(self.CONTEXT_MARKER_END) + if end_of_marker < len(content) and content[end_of_marker] == "\n": + end_of_marker += 1 + new_content = content[:start_idx] + section + content[end_of_marker:] + else: + # Markers not found — append + if content and not content.endswith("\n"): + content += "\n" + new_content = content + "\n" + section + else: + ctx_path.parent.mkdir(parents=True, exist_ok=True) + new_content = section + + normalized = new_content.replace("\r\n", "\n") + ctx_path.write_bytes(normalized.encode("utf-8")) + return ctx_path + + def remove_context_section(self, project_root: Path) -> bool: + """Remove the managed section from the agent context file. + + Returns ``True`` if the section was found and removed. If the + file becomes empty (or whitespace-only) after removal it is + deleted. + """ + if not self.context_file: + return False + + ctx_path = project_root / self.context_file + if not ctx_path.exists(): + return False + + content = ctx_path.read_text(encoding="utf-8") + start_idx = content.find(self.CONTEXT_MARKER_START) + end_idx = content.find(self.CONTEXT_MARKER_END) + + if start_idx == -1 or end_idx == -1: + return False + + end_of_marker = end_idx + len(self.CONTEXT_MARKER_END) + if end_of_marker < len(content) and content[end_of_marker] == "\n": + end_of_marker += 1 + + # Also strip a blank line before the section if present + if start_idx > 0 and content[start_idx - 1] == "\n": + if start_idx > 1 and content[start_idx - 2] == "\n": + start_idx -= 1 + + new_content = content[:start_idx] + content[end_of_marker:] + + if not new_content.strip(): + ctx_path.unlink() + else: + normalized = new_content.replace("\r\n", "\n") + ctx_path.write_bytes(normalized.encode("utf-8")) + + return True + @staticmethod def process_template( content: str, agent_name: str, script_type: str, arg_placeholder: str = "$ARGUMENTS", + context_file: str = "", ) -> str: """Process a raw command template into agent-ready content. Performs the same transformations as the release script: 1. Extract ``scripts.`` value from YAML frontmatter 2. Replace ``{SCRIPT}`` with the extracted script command - 3. Extract ``agent_scripts.`` and replace ``{AGENT_SCRIPT}`` - 4. Strip ``scripts:`` and ``agent_scripts:`` sections from frontmatter - 5. Replace ``{ARGS}`` and ``$ARGUMENTS`` with *arg_placeholder* - 6. Replace ``__AGENT__`` with *agent_name* + 3. Strip ``scripts:`` section from frontmatter + 4. Replace ``{ARGS}`` and ``$ARGUMENTS`` with *arg_placeholder* + 5. Replace ``__AGENT__`` with *agent_name* + 6. Replace ``__CONTEXT_FILE__`` with *context_file* 7. Rewrite paths: ``scripts/`` → ``.specify/scripts/`` etc. """ # 1. Extract script command from frontmatter @@ -421,25 +534,7 @@ def process_template( if script_command: content = content.replace("{SCRIPT}", script_command) - # 3. Extract agent_script command - agent_script_command = "" - in_agent_scripts = False - for line in content.splitlines(): - if line.strip() == "agent_scripts:": - in_agent_scripts = True - continue - if in_agent_scripts and line and not line[0].isspace(): - in_agent_scripts = False - if in_agent_scripts: - m = script_pattern.match(line) - if m: - agent_script_command = m.group(1).strip() - break - - if agent_script_command: - content = content.replace("{AGENT_SCRIPT}", agent_script_command) - - # 4. Strip scripts: and agent_scripts: sections from frontmatter + # 3. Strip scripts: section from frontmatter lines = content.splitlines(keepends=True) output_lines: list[str] = [] in_frontmatter = False @@ -457,23 +552,26 @@ def process_template( output_lines.append(line) continue if in_frontmatter: - if stripped in ("scripts:", "agent_scripts:"): + if stripped == "scripts:": skip_section = True continue if skip_section: if line[0:1].isspace(): - continue # skip indented content under scripts/agent_scripts + continue # skip indented content under scripts skip_section = False output_lines.append(line) content = "".join(output_lines) - # 5. Replace {ARGS} and $ARGUMENTS + # 4. Replace {ARGS} and $ARGUMENTS content = content.replace("{ARGS}", arg_placeholder) content = content.replace("$ARGUMENTS", arg_placeholder) - # 6. Replace __AGENT__ + # 5. Replace __AGENT__ content = content.replace("__AGENT__", agent_name) + # 6. Replace __CONTEXT_FILE__ + content = content.replace("__CONTEXT_FILE__", context_file) + # 7. Rewrite paths — delegate to the shared implementation in # CommandRegistrar so extension-local paths are preserved and # boundary rules stay consistent across the codebase. @@ -526,6 +624,9 @@ def setup( self.record_file_in_manifest(dst_file, project_root, manifest) created.append(dst_file) + # Upsert managed context section into the agent context file + self.upsert_context_section(project_root) + return created def teardown( @@ -539,9 +640,11 @@ def teardown( Delegates to ``manifest.uninstall()`` which only removes files whose hash still matches the recorded value (unless *force*). + Also removes the managed context section from the agent file. Returns ``(removed, skipped)`` file lists. """ + self.remove_context_section(project_root) return manifest.uninstall(project_root, force=force) # -- Convenience helpers for subclasses ------------------------------- @@ -579,8 +682,8 @@ class MarkdownIntegration(IntegrationBase): (and optionally ``context_file``). Everything else is inherited. ``setup()`` processes command templates (replacing ``{SCRIPT}``, - ``{ARGS}``, ``__AGENT__``, rewriting paths) and installs - integration-specific scripts (``update-context.sh`` / ``.ps1``). + ``{ARGS}``, ``__AGENT__``, rewriting paths) and upserts the + managed context section into the agent context file. """ def build_exec_args( @@ -638,7 +741,8 @@ def setup( for src_file in templates: raw = src_file.read_text(encoding="utf-8") processed = self.process_template( - raw, self.key, script_type, arg_placeholder + raw, self.key, script_type, arg_placeholder, + context_file=self.context_file or "", ) dst_name = self.command_filename(src_file.stem) dst_file = self.write_file_and_record( @@ -646,7 +750,9 @@ def setup( ) created.append(dst_file) - created.extend(self.install_scripts(project_root, manifest)) + # Upsert managed context section into the agent context file + self.upsert_context_section(project_root) + return created @@ -841,7 +947,8 @@ def setup( raw = src_file.read_text(encoding="utf-8") description = self._extract_description(raw) processed = self.process_template( - raw, self.key, script_type, arg_placeholder + raw, self.key, script_type, arg_placeholder, + context_file=self.context_file or "", ) _, body = self._split_frontmatter(processed) toml_content = self._render_toml(description, body) @@ -851,7 +958,9 @@ def setup( ) created.append(dst_file) - created.extend(self.install_scripts(project_root, manifest)) + # Upsert managed context section into the agent context file + self.upsert_context_section(project_root) + return created @@ -1021,7 +1130,8 @@ def setup( title = self._human_title(src_file.stem) processed = self.process_template( - raw, self.key, script_type, arg_placeholder + raw, self.key, script_type, arg_placeholder, + context_file=self.context_file or "", ) _, body = self._split_frontmatter(processed) yaml_content = self._render_yaml( @@ -1033,7 +1143,9 @@ def setup( ) created.append(dst_file) - created.extend(self.install_scripts(project_root, manifest)) + # Upsert managed context section into the agent context file + self.upsert_context_section(project_root) + return created @@ -1176,7 +1288,8 @@ def setup( # Process body through the standard template pipeline processed_body = self.process_template( - raw, self.key, script_type, arg_placeholder + raw, self.key, script_type, arg_placeholder, + context_file=self.context_file or "", ) # Strip the processed frontmatter — we rebuild it for skills. # Preserve leading whitespace in the body to match release ZIP @@ -1220,5 +1333,7 @@ def _quote(v: str) -> str: ) created.append(dst) - created.extend(self.install_scripts(project_root, manifest)) + # Upsert managed context section into the agent context file + self.upsert_context_section(project_root) + return created diff --git a/src/specify_cli/integrations/bob/scripts/update-context.ps1 b/src/specify_cli/integrations/bob/scripts/update-context.ps1 deleted file mode 100644 index 188860899f..0000000000 --- a/src/specify_cli/integrations/bob/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — IBM Bob integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType bob diff --git a/src/specify_cli/integrations/bob/scripts/update-context.sh b/src/specify_cli/integrations/bob/scripts/update-context.sh deleted file mode 100755 index 0228603fea..0000000000 --- a/src/specify_cli/integrations/bob/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — IBM Bob integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" bob diff --git a/src/specify_cli/integrations/claude/scripts/update-context.ps1 b/src/specify_cli/integrations/claude/scripts/update-context.ps1 deleted file mode 100644 index 837974d47a..0000000000 --- a/src/specify_cli/integrations/claude/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Claude Code integration: create/update CLAUDE.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType claude diff --git a/src/specify_cli/integrations/claude/scripts/update-context.sh b/src/specify_cli/integrations/claude/scripts/update-context.sh deleted file mode 100755 index 4b83855a27..0000000000 --- a/src/specify_cli/integrations/claude/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Claude Code integration: create/update CLAUDE.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" claude diff --git a/src/specify_cli/integrations/codebuddy/scripts/update-context.ps1 b/src/specify_cli/integrations/codebuddy/scripts/update-context.ps1 deleted file mode 100644 index 0269392c09..0000000000 --- a/src/specify_cli/integrations/codebuddy/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — CodeBuddy integration: create/update CODEBUDDY.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType codebuddy diff --git a/src/specify_cli/integrations/codebuddy/scripts/update-context.sh b/src/specify_cli/integrations/codebuddy/scripts/update-context.sh deleted file mode 100755 index d57ddc3560..0000000000 --- a/src/specify_cli/integrations/codebuddy/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — CodeBuddy integration: create/update CODEBUDDY.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" codebuddy diff --git a/src/specify_cli/integrations/codex/scripts/update-context.ps1 b/src/specify_cli/integrations/codex/scripts/update-context.ps1 deleted file mode 100644 index d73a5a4d34..0000000000 --- a/src/specify_cli/integrations/codex/scripts/update-context.ps1 +++ /dev/null @@ -1,17 +0,0 @@ -# update-context.ps1 — Codex CLI integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. - -$ErrorActionPreference = 'Stop' - -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType codex diff --git a/src/specify_cli/integrations/codex/scripts/update-context.sh b/src/specify_cli/integrations/codex/scripts/update-context.sh deleted file mode 100755 index 512d6e91d3..0000000000 --- a/src/specify_cli/integrations/codex/scripts/update-context.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Codex CLI integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. - -set -euo pipefail - -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" codex diff --git a/src/specify_cli/integrations/copilot/__init__.py b/src/specify_cli/integrations/copilot/__init__.py index e389138a84..be7fc819f6 100644 --- a/src/specify_cli/integrations/copilot/__init__.py +++ b/src/specify_cli/integrations/copilot/__init__.py @@ -183,7 +183,10 @@ def setup( # 1. Process and write command files as .agent.md for src_file in templates: raw = src_file.read_text(encoding="utf-8") - processed = self.process_template(raw, self.key, script_type, arg_placeholder) + processed = self.process_template( + raw, self.key, script_type, arg_placeholder, + context_file=self.context_file or "", + ) dst_name = self.command_filename(src_file.stem) dst_file = self.write_file_and_record( processed, dest / dst_name, project_root, manifest @@ -217,8 +220,8 @@ def setup( self.record_file_in_manifest(dst_settings, project_root, manifest) created.append(dst_settings) - # 4. Install integration-specific update-context scripts - created.extend(self.install_scripts(project_root, manifest)) + # 4. Upsert managed context section into the agent context file + self.upsert_context_section(project_root) return created diff --git a/src/specify_cli/integrations/copilot/scripts/update-context.ps1 b/src/specify_cli/integrations/copilot/scripts/update-context.ps1 deleted file mode 100644 index 26e746a789..0000000000 --- a/src/specify_cli/integrations/copilot/scripts/update-context.ps1 +++ /dev/null @@ -1,32 +0,0 @@ -# update-context.ps1 — Copilot integration: create/update .github/copilot-instructions.md -# -# This is the copilot-specific implementation that produces the GitHub -# Copilot instructions file. The shared dispatcher reads -# .specify/integration.json and calls this script. -# -# NOTE: This script is not yet active. It will be activated in Stage 7 -# when the shared update-agent-context.ps1 replaces its switch statement -# with integration.json-based dispatch. The shared script must also be -# refactored to support SPECKIT_SOURCE_ONLY (guard the Main call) before -# dot-sourcing will work. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -# Invoke shared update-agent-context script as a separate process. -# Dot-sourcing is unsafe until that script guards its Main call. -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType copilot diff --git a/src/specify_cli/integrations/copilot/scripts/update-context.sh b/src/specify_cli/integrations/copilot/scripts/update-context.sh deleted file mode 100644 index c7f3bc60b5..0000000000 --- a/src/specify_cli/integrations/copilot/scripts/update-context.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Copilot integration: create/update .github/copilot-instructions.md -# -# This is the copilot-specific implementation that produces the GitHub -# Copilot instructions file. The shared dispatcher reads -# .specify/integration.json and calls this script. -# -# NOTE: This script is not yet active. It will be activated in Stage 7 -# when the shared update-agent-context.sh replaces its case statement -# with integration.json-based dispatch. The shared script must also be -# refactored to support SPECKIT_SOURCE_ONLY (guard the main logic) -# before sourcing will work. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -# Invoke shared update-agent-context script as a separate process. -# Sourcing is unsafe until that script guards its main logic. -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" copilot diff --git a/src/specify_cli/integrations/cursor_agent/scripts/update-context.ps1 b/src/specify_cli/integrations/cursor_agent/scripts/update-context.ps1 deleted file mode 100644 index 4ce50a4873..0000000000 --- a/src/specify_cli/integrations/cursor_agent/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Cursor integration: create/update .cursor/rules/specify-rules.mdc -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType cursor-agent diff --git a/src/specify_cli/integrations/cursor_agent/scripts/update-context.sh b/src/specify_cli/integrations/cursor_agent/scripts/update-context.sh deleted file mode 100755 index 597ca2289c..0000000000 --- a/src/specify_cli/integrations/cursor_agent/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Cursor integration: create/update .cursor/rules/specify-rules.mdc -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" cursor-agent diff --git a/src/specify_cli/integrations/forge/__init__.py b/src/specify_cli/integrations/forge/__init__.py index e1c4d9da62..a941d4c331 100644 --- a/src/specify_cli/integrations/forge/__init__.py +++ b/src/specify_cli/integrations/forge/__init__.py @@ -130,7 +130,10 @@ def setup( for src_file in templates: raw = src_file.read_text(encoding="utf-8") # Process template with standard MarkdownIntegration logic - processed = self.process_template(raw, self.key, script_type, arg_placeholder) + processed = self.process_template( + raw, self.key, script_type, arg_placeholder, + context_file=self.context_file or "", + ) # FORGE-SPECIFIC: Ensure any remaining $ARGUMENTS placeholders are # converted to {{parameters}} @@ -145,8 +148,8 @@ def setup( ) created.append(dst_file) - # Install integration-specific update-context scripts - created.extend(self.install_scripts(project_root, manifest)) + # Upsert managed context section into the agent context file + self.upsert_context_section(project_root) return created diff --git a/src/specify_cli/integrations/forge/scripts/update-context.ps1 b/src/specify_cli/integrations/forge/scripts/update-context.ps1 deleted file mode 100644 index 474a9c6d0b..0000000000 --- a/src/specify_cli/integrations/forge/scripts/update-context.ps1 +++ /dev/null @@ -1,33 +0,0 @@ -# update-context.ps1 — Forge integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -$sharedScript = "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" - -# Always delegate to the shared updater; fail clearly if it is unavailable. -if (-not (Test-Path $sharedScript)) { - Write-Error "Error: shared agent context updater not found: $sharedScript" - Write-Error "Forge integration requires support in scripts/powershell/update-agent-context.ps1." - exit 1 -} - -& $sharedScript -AgentType forge -exit $LASTEXITCODE diff --git a/src/specify_cli/integrations/forge/scripts/update-context.sh b/src/specify_cli/integrations/forge/scripts/update-context.sh deleted file mode 100755 index 2a5c46e1d1..0000000000 --- a/src/specify_cli/integrations/forge/scripts/update-context.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Forge integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -shared_script="$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" - -# Always delegate to the shared updater; fail clearly if it is unavailable. -if [ ! -x "$shared_script" ]; then - echo "Error: shared agent context updater not found or not executable:" >&2 - echo " $shared_script" >&2 - echo "Forge integration requires support in scripts/bash/update-agent-context.sh." >&2 - exit 1 -fi - -exec "$shared_script" forge diff --git a/src/specify_cli/integrations/gemini/scripts/update-context.ps1 b/src/specify_cli/integrations/gemini/scripts/update-context.ps1 deleted file mode 100644 index 51c9e0bc83..0000000000 --- a/src/specify_cli/integrations/gemini/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Gemini CLI integration: create/update GEMINI.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType gemini diff --git a/src/specify_cli/integrations/gemini/scripts/update-context.sh b/src/specify_cli/integrations/gemini/scripts/update-context.sh deleted file mode 100644 index c4e5003a55..0000000000 --- a/src/specify_cli/integrations/gemini/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Gemini CLI integration: create/update GEMINI.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" gemini diff --git a/src/specify_cli/integrations/generic/__init__.py b/src/specify_cli/integrations/generic/__init__.py index 4107c48690..fdaee4ed04 100644 --- a/src/specify_cli/integrations/generic/__init__.py +++ b/src/specify_cli/integrations/generic/__init__.py @@ -31,7 +31,7 @@ class GenericIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = None + context_file = "AGENTS.md" @classmethod def options(cls) -> list[IntegrationOption]: @@ -122,12 +122,17 @@ def setup( for src_file in templates: raw = src_file.read_text(encoding="utf-8") - processed = self.process_template(raw, self.key, script_type, arg_placeholder) + processed = self.process_template( + raw, self.key, script_type, arg_placeholder, + context_file=self.context_file or "", + ) dst_name = self.command_filename(src_file.stem) dst_file = self.write_file_and_record( processed, dest / dst_name, project_root, manifest ) created.append(dst_file) - created.extend(self.install_scripts(project_root, manifest)) + # Upsert managed context section into the agent context file + self.upsert_context_section(project_root) + return created diff --git a/src/specify_cli/integrations/generic/scripts/update-context.ps1 b/src/specify_cli/integrations/generic/scripts/update-context.ps1 deleted file mode 100644 index 2e9467f801..0000000000 --- a/src/specify_cli/integrations/generic/scripts/update-context.ps1 +++ /dev/null @@ -1,17 +0,0 @@ -# update-context.ps1 — Generic integration: create/update context file -# -# Thin wrapper that delegates to the shared update-agent-context script. - -$ErrorActionPreference = 'Stop' - -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType generic diff --git a/src/specify_cli/integrations/generic/scripts/update-context.sh b/src/specify_cli/integrations/generic/scripts/update-context.sh deleted file mode 100755 index d8ad30a7b8..0000000000 --- a/src/specify_cli/integrations/generic/scripts/update-context.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Generic integration: create/update context file -# -# Thin wrapper that delegates to the shared update-agent-context script. - -set -euo pipefail - -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" generic diff --git a/src/specify_cli/integrations/goose/scripts/update-context.ps1 b/src/specify_cli/integrations/goose/scripts/update-context.ps1 deleted file mode 100644 index eeb31f6296..0000000000 --- a/src/specify_cli/integrations/goose/scripts/update-context.ps1 +++ /dev/null @@ -1,33 +0,0 @@ -# update-context.ps1 — Goose integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -$sharedScript = "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" - -# Always delegate to the shared updater; fail clearly if it is unavailable. -if (-not (Test-Path $sharedScript)) { - Write-Error "Error: shared agent context updater not found: $sharedScript" - Write-Error "Goose integration requires support in scripts/powershell/update-agent-context.ps1." - exit 1 -} - -& $sharedScript -AgentType goose -exit $LASTEXITCODE diff --git a/src/specify_cli/integrations/goose/scripts/update-context.sh b/src/specify_cli/integrations/goose/scripts/update-context.sh deleted file mode 100755 index 759ae3045a..0000000000 --- a/src/specify_cli/integrations/goose/scripts/update-context.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Goose integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -shared_script="$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" - -# Always delegate to the shared updater; fail clearly if it is unavailable. -if [ ! -x "$shared_script" ]; then - echo "Error: shared agent context updater not found or not executable:" >&2 - echo " $shared_script" >&2 - echo "Goose integration requires support in scripts/bash/update-agent-context.sh." >&2 - exit 1 -fi - -exec "$shared_script" goose diff --git a/src/specify_cli/integrations/iflow/scripts/update-context.ps1 b/src/specify_cli/integrations/iflow/scripts/update-context.ps1 deleted file mode 100644 index b502d4182a..0000000000 --- a/src/specify_cli/integrations/iflow/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — iFlow CLI integration: create/update IFLOW.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType iflow diff --git a/src/specify_cli/integrations/iflow/scripts/update-context.sh b/src/specify_cli/integrations/iflow/scripts/update-context.sh deleted file mode 100755 index 5080402071..0000000000 --- a/src/specify_cli/integrations/iflow/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — iFlow CLI integration: create/update IFLOW.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" iflow diff --git a/src/specify_cli/integrations/junie/scripts/update-context.ps1 b/src/specify_cli/integrations/junie/scripts/update-context.ps1 deleted file mode 100644 index 5a32432132..0000000000 --- a/src/specify_cli/integrations/junie/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Junie integration: create/update .junie/AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType junie diff --git a/src/specify_cli/integrations/junie/scripts/update-context.sh b/src/specify_cli/integrations/junie/scripts/update-context.sh deleted file mode 100755 index f4c8ba6c0e..0000000000 --- a/src/specify_cli/integrations/junie/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Junie integration: create/update .junie/AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" junie diff --git a/src/specify_cli/integrations/kilocode/scripts/update-context.ps1 b/src/specify_cli/integrations/kilocode/scripts/update-context.ps1 deleted file mode 100644 index d87e7ef59f..0000000000 --- a/src/specify_cli/integrations/kilocode/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Kilo Code integration: create/update .kilocode/rules/specify-rules.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType kilocode diff --git a/src/specify_cli/integrations/kilocode/scripts/update-context.sh b/src/specify_cli/integrations/kilocode/scripts/update-context.sh deleted file mode 100755 index 132c0403f3..0000000000 --- a/src/specify_cli/integrations/kilocode/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Kilo Code integration: create/update .kilocode/rules/specify-rules.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" kilocode diff --git a/src/specify_cli/integrations/kimi/scripts/update-context.ps1 b/src/specify_cli/integrations/kimi/scripts/update-context.ps1 deleted file mode 100644 index aa6678d052..0000000000 --- a/src/specify_cli/integrations/kimi/scripts/update-context.ps1 +++ /dev/null @@ -1,17 +0,0 @@ -# update-context.ps1 — Kimi Code integration: create/update KIMI.md -# -# Thin wrapper that delegates to the shared update-agent-context script. - -$ErrorActionPreference = 'Stop' - -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType kimi diff --git a/src/specify_cli/integrations/kimi/scripts/update-context.sh b/src/specify_cli/integrations/kimi/scripts/update-context.sh deleted file mode 100755 index 2f81bc2a48..0000000000 --- a/src/specify_cli/integrations/kimi/scripts/update-context.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Kimi Code integration: create/update KIMI.md -# -# Thin wrapper that delegates to the shared update-agent-context script. - -set -euo pipefail - -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" kimi diff --git a/src/specify_cli/integrations/kiro_cli/scripts/update-context.ps1 b/src/specify_cli/integrations/kiro_cli/scripts/update-context.ps1 deleted file mode 100644 index 7dd2b35fb7..0000000000 --- a/src/specify_cli/integrations/kiro_cli/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Kiro CLI integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType kiro-cli diff --git a/src/specify_cli/integrations/kiro_cli/scripts/update-context.sh b/src/specify_cli/integrations/kiro_cli/scripts/update-context.sh deleted file mode 100755 index fa258edc75..0000000000 --- a/src/specify_cli/integrations/kiro_cli/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Kiro CLI integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" kiro-cli diff --git a/src/specify_cli/integrations/opencode/scripts/update-context.ps1 b/src/specify_cli/integrations/opencode/scripts/update-context.ps1 deleted file mode 100644 index 4bba02b455..0000000000 --- a/src/specify_cli/integrations/opencode/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — opencode integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType opencode diff --git a/src/specify_cli/integrations/opencode/scripts/update-context.sh b/src/specify_cli/integrations/opencode/scripts/update-context.sh deleted file mode 100755 index 24c7e60251..0000000000 --- a/src/specify_cli/integrations/opencode/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — opencode integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" opencode diff --git a/src/specify_cli/integrations/pi/scripts/update-context.ps1 b/src/specify_cli/integrations/pi/scripts/update-context.ps1 deleted file mode 100644 index 6362118a5b..0000000000 --- a/src/specify_cli/integrations/pi/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Pi Coding Agent integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType pi diff --git a/src/specify_cli/integrations/pi/scripts/update-context.sh b/src/specify_cli/integrations/pi/scripts/update-context.sh deleted file mode 100755 index 1ad84c95a2..0000000000 --- a/src/specify_cli/integrations/pi/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Pi Coding Agent integration: create/update AGENTS.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" pi diff --git a/src/specify_cli/integrations/qodercli/scripts/update-context.ps1 b/src/specify_cli/integrations/qodercli/scripts/update-context.ps1 deleted file mode 100644 index 1fa007a168..0000000000 --- a/src/specify_cli/integrations/qodercli/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Qoder CLI integration: create/update QODER.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType qodercli diff --git a/src/specify_cli/integrations/qodercli/scripts/update-context.sh b/src/specify_cli/integrations/qodercli/scripts/update-context.sh deleted file mode 100755 index d371ad7952..0000000000 --- a/src/specify_cli/integrations/qodercli/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Qoder CLI integration: create/update QODER.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" qodercli diff --git a/src/specify_cli/integrations/qwen/scripts/update-context.ps1 b/src/specify_cli/integrations/qwen/scripts/update-context.ps1 deleted file mode 100644 index 24e4c90fab..0000000000 --- a/src/specify_cli/integrations/qwen/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Qwen Code integration: create/update QWEN.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType qwen diff --git a/src/specify_cli/integrations/qwen/scripts/update-context.sh b/src/specify_cli/integrations/qwen/scripts/update-context.sh deleted file mode 100755 index d1c62eb161..0000000000 --- a/src/specify_cli/integrations/qwen/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Qwen Code integration: create/update QWEN.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" qwen diff --git a/src/specify_cli/integrations/roo/scripts/update-context.ps1 b/src/specify_cli/integrations/roo/scripts/update-context.ps1 deleted file mode 100644 index d1dec923ed..0000000000 --- a/src/specify_cli/integrations/roo/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Roo Code integration: create/update .roo/rules/specify-rules.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType roo diff --git a/src/specify_cli/integrations/roo/scripts/update-context.sh b/src/specify_cli/integrations/roo/scripts/update-context.sh deleted file mode 100755 index 8fe255cb1b..0000000000 --- a/src/specify_cli/integrations/roo/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Roo Code integration: create/update .roo/rules/specify-rules.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" roo diff --git a/src/specify_cli/integrations/shai/scripts/update-context.ps1 b/src/specify_cli/integrations/shai/scripts/update-context.ps1 deleted file mode 100644 index 2c621c76ac..0000000000 --- a/src/specify_cli/integrations/shai/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — SHAI integration: create/update SHAI.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType shai diff --git a/src/specify_cli/integrations/shai/scripts/update-context.sh b/src/specify_cli/integrations/shai/scripts/update-context.sh deleted file mode 100755 index 093b9d1f76..0000000000 --- a/src/specify_cli/integrations/shai/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — SHAI integration: create/update SHAI.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" shai diff --git a/src/specify_cli/integrations/tabnine/scripts/update-context.ps1 b/src/specify_cli/integrations/tabnine/scripts/update-context.ps1 deleted file mode 100644 index 0ffb3a1649..0000000000 --- a/src/specify_cli/integrations/tabnine/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Tabnine CLI integration: create/update TABNINE.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType tabnine diff --git a/src/specify_cli/integrations/tabnine/scripts/update-context.sh b/src/specify_cli/integrations/tabnine/scripts/update-context.sh deleted file mode 100644 index fe5050b6e9..0000000000 --- a/src/specify_cli/integrations/tabnine/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Tabnine CLI integration: create/update TABNINE.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" tabnine diff --git a/src/specify_cli/integrations/trae/scripts/update-context.ps1 b/src/specify_cli/integrations/trae/scripts/update-context.ps1 deleted file mode 100644 index ae9a3d1cd0..0000000000 --- a/src/specify_cli/integrations/trae/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Trae integration: create/update .trae/rules/project_rules.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType trae diff --git a/src/specify_cli/integrations/trae/scripts/update-context.sh b/src/specify_cli/integrations/trae/scripts/update-context.sh deleted file mode 100755 index 32e5c16b29..0000000000 --- a/src/specify_cli/integrations/trae/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Trae integration: create/update .trae/rules/project_rules.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" trae diff --git a/src/specify_cli/integrations/vibe/scripts/update-context.ps1 b/src/specify_cli/integrations/vibe/scripts/update-context.ps1 deleted file mode 100644 index d82ce3389c..0000000000 --- a/src/specify_cli/integrations/vibe/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Mistral Vibe integration: create/update .vibe/agents/specify-agents.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType vibe diff --git a/src/specify_cli/integrations/vibe/scripts/update-context.sh b/src/specify_cli/integrations/vibe/scripts/update-context.sh deleted file mode 100755 index f924cdb896..0000000000 --- a/src/specify_cli/integrations/vibe/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Mistral Vibe integration: create/update .vibe/agents/specify-agents.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" vibe diff --git a/src/specify_cli/integrations/windsurf/scripts/update-context.ps1 b/src/specify_cli/integrations/windsurf/scripts/update-context.ps1 deleted file mode 100644 index b5fe1d0c0a..0000000000 --- a/src/specify_cli/integrations/windsurf/scripts/update-context.ps1 +++ /dev/null @@ -1,23 +0,0 @@ -# update-context.ps1 — Windsurf integration: create/update .windsurf/rules/specify-rules.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -$ErrorActionPreference = 'Stop' - -# Derive repo root from script location (walks up to find .specify/) -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null } -# If git did not return a repo root, or the git root does not contain .specify, -# fall back to walking up from the script directory to find the initialized project root. -if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = $scriptDir - $fsRoot = [System.IO.Path]::GetPathRoot($repoRoot) - while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) { - $repoRoot = Split-Path -Parent $repoRoot - } -} - -& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType windsurf diff --git a/src/specify_cli/integrations/windsurf/scripts/update-context.sh b/src/specify_cli/integrations/windsurf/scripts/update-context.sh deleted file mode 100755 index b9a78d320e..0000000000 --- a/src/specify_cli/integrations/windsurf/scripts/update-context.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# update-context.sh — Windsurf integration: create/update .windsurf/rules/specify-rules.md -# -# Thin wrapper that delegates to the shared update-agent-context script. -# Activated in Stage 7 when the shared script uses integration.json dispatch. -# -# Until then, this delegates to the shared script as a subprocess. - -set -euo pipefail - -# Derive repo root from script location (walks up to find .specify/) -_script_dir="$(cd "$(dirname "$0")" && pwd)" -_root="$_script_dir" -while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done -if [ -z "${REPO_ROOT:-}" ]; then - if [ -d "$_root/.specify" ]; then - REPO_ROOT="$_root" - else - git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" - if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then - REPO_ROOT="$git_root" - else - REPO_ROOT="$_root" - fi - fi -fi - -exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" windsurf diff --git a/templates/agent-file-template.md b/templates/agent-file-template.md deleted file mode 100644 index 4cc7fd6678..0000000000 --- a/templates/agent-file-template.md +++ /dev/null @@ -1,28 +0,0 @@ -# [PROJECT NAME] Development Guidelines - -Auto-generated from all feature plans. Last updated: [DATE] - -## Active Technologies - -[EXTRACTED FROM ALL PLAN.MD FILES] - -## Project Structure - -```text -[ACTUAL STRUCTURE FROM PLANS] -``` - -## Commands - -[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES] - -## Code Style - -[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE] - -## Recent Changes - -[LAST 3 FEATURES AND WHAT THEY ADDED] - - - diff --git a/templates/commands/plan.md b/templates/commands/plan.md index 4f1e9ed295..df30865a0d 100644 --- a/templates/commands/plan.md +++ b/templates/commands/plan.md @@ -11,9 +11,6 @@ handoffs: scripts: sh: scripts/bash/setup-plan.sh --json ps: scripts/powershell/setup-plan.ps1 -Json -agent_scripts: - sh: scripts/bash/update-agent-context.sh __AGENT__ - ps: scripts/powershell/update-agent-context.ps1 -AgentType __AGENT__ --- ## User Input @@ -145,13 +142,9 @@ You **MUST** consider the user input before proceeding (if not empty). - Skip if project is purely internal (build scripts, one-off tools, etc.) 3. **Agent context update**: - - Run `{AGENT_SCRIPT}` - - These scripts detect which AI agent is in use - - Update the appropriate agent-specific context file - - Add only new technology from current plan - - Preserve manual additions between markers + - Update the plan reference between the `` and `` markers in `__CONTEXT_FILE__` to point to the plan file created in step 1 (the IMPL_PLAN path) -**Output**: data-model.md, /contracts/*, quickstart.md, agent-specific file +**Output**: data-model.md, /contracts/*, quickstart.md, updated agent context file ## Key rules diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index bd73ccd664..04a91682e8 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -56,14 +56,19 @@ def test_integration_copilot_creates_files(self, tmp_path): data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) assert data["integration"] == "copilot" - assert "scripts" in data - assert "update-context" in data["scripts"] opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8")) assert opts["integration"] == "copilot" + assert opts["context_file"] == ".github/copilot-instructions.md" assert (project / ".specify" / "integrations" / "copilot.manifest.json").exists() - assert (project / ".specify" / "integrations" / "copilot" / "scripts" / "update-context.sh").exists() + + # Context section should be upserted into the copilot instructions file + ctx_file = project / ".github" / "copilot-instructions.md" + assert ctx_file.exists() + ctx_content = ctx_file.read_text(encoding="utf-8") + assert "" in ctx_content + assert "" in ctx_content shared_manifest = project / ".specify" / "integrations" / "speckit.manifest.json" assert shared_manifest.exists() diff --git a/tests/integrations/test_integration_base_markdown.py b/tests/integrations/test_integration_base_markdown.py index 3700d35de5..f22bb298b4 100644 --- a/tests/integrations/test_integration_base_markdown.py +++ b/tests/integrations/test_integration_base_markdown.py @@ -99,7 +99,23 @@ def test_templates_are_processed(self, tmp_path): assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__" assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}" assert "\nscripts:\n" not in content, f"{f.name} has unstripped scripts: block" - assert "\nagent_scripts:\n" not in content, f"{f.name} has unstripped agent_scripts: block" + + def test_plan_references_correct_context_file(self, tmp_path): + """The generated plan command must reference this integration's context file.""" + i = get_integration(self.KEY) + if not i.context_file: + return + m = IntegrationManifest(self.KEY, tmp_path) + i.setup(tmp_path, m) + plan_file = i.commands_dest(tmp_path) / i.command_filename("plan") + assert plan_file.exists(), f"Plan file {plan_file} not created" + content = plan_file.read_text(encoding="utf-8") + assert i.context_file in content, ( + f"Plan command should reference {i.context_file!r} but it was not found in {plan_file.name}" + ) + assert "__CONTEXT_FILE__" not in content, ( + f"Plan command has unprocessed __CONTEXT_FILE__ placeholder in {plan_file.name}" + ) def test_all_files_tracked_in_manifest(self, tmp_path): i = get_integration(self.KEY) @@ -132,30 +148,35 @@ def test_modified_file_survives_uninstall(self, tmp_path): assert modified_file.exists() assert modified_file in skipped - # -- Scripts ---------------------------------------------------------- - - def test_setup_installs_update_context_scripts(self, tmp_path): - i = get_integration(self.KEY) - m = IntegrationManifest(self.KEY, tmp_path) - created = i.setup(tmp_path, m) - scripts_dir = tmp_path / ".specify" / "integrations" / self.KEY / "scripts" - assert scripts_dir.is_dir(), f"Scripts directory not created for {self.KEY}" - assert (scripts_dir / "update-context.sh").exists() - assert (scripts_dir / "update-context.ps1").exists() + # -- Context section --------------------------------------------------- - def test_scripts_tracked_in_manifest(self, tmp_path): + def test_setup_upserts_context_section(self, tmp_path): i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) - script_rels = [k for k in m.files if "update-context" in k] - assert len(script_rels) >= 2 - - def test_sh_script_is_executable(self, tmp_path): + if i.context_file: + ctx_path = tmp_path / i.context_file + assert ctx_path.exists(), f"Context file {i.context_file} not created for {self.KEY}" + content = ctx_path.read_text(encoding="utf-8") + assert "" in content + assert "" in content + assert "read the current plan" in content + + def test_teardown_removes_context_section(self, tmp_path): i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) - sh = tmp_path / ".specify" / "integrations" / self.KEY / "scripts" / "update-context.sh" - assert os.access(sh, os.X_OK) + m.save() + if i.context_file: + ctx_path = tmp_path / i.context_file + # Add user content around the section + content = ctx_path.read_text(encoding="utf-8") + ctx_path.write_text("# My Rules\n\n" + content + "\n# Footer\n", encoding="utf-8") + i.teardown(tmp_path, m) + remaining = ctx_path.read_text(encoding="utf-8") + assert "" not in remaining + assert "" not in remaining + assert "# My Rules" in remaining # -- CLI auto-promote ------------------------------------------------- @@ -203,6 +224,30 @@ def test_integration_flag_creates_files(self, tmp_path): commands = sorted(cmd_dir.glob("speckit.*")) assert len(commands) > 0, f"No command files in {cmd_dir}" + def test_init_options_includes_context_file(self, tmp_path): + """init-options.json must include context_file for the active integration.""" + import json + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"opts-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", self.KEY, "--script", "sh", + "--no-git", "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + opts = json.loads((project / ".specify" / "init-options.json").read_text()) + i = get_integration(self.KEY) + assert opts.get("context_file") == i.context_file, ( + f"Expected context_file={i.context_file!r}, got {opts.get('context_file')!r}" + ) + # -- Complete file inventory ------------------------------------------ COMMAND_STEMS = [ @@ -220,10 +265,6 @@ def _expected_files(self, script_variant: str) -> list[str]: for stem in self.COMMAND_STEMS: files.append(f"{cmd_dir}/speckit.{stem}.md") - # Integration scripts - files.append(f".specify/integrations/{self.KEY}/scripts/update-context.ps1") - files.append(f".specify/integrations/{self.KEY}/scripts/update-context.sh") - # Framework files files.append(f".specify/integration.json") files.append(f".specify/init-options.json") @@ -232,14 +273,14 @@ def _expected_files(self, script_variant: str) -> list[str]: if script_variant == "sh": for name in ["check-prerequisites.sh", "common.sh", "create-new-feature.sh", - "setup-plan.sh", "update-agent-context.sh"]: + "setup-plan.sh"]: files.append(f".specify/scripts/bash/{name}") else: for name in ["check-prerequisites.ps1", "common.ps1", "create-new-feature.ps1", - "setup-plan.ps1", "update-agent-context.ps1"]: + "setup-plan.ps1"]: files.append(f".specify/scripts/powershell/{name}") - for name in ["agent-file-template.md", "checklist-template.md", + for name in ["checklist-template.md", "constitution-template.md", "plan-template.md", "spec-template.md", "tasks-template.md"]: files.append(f".specify/templates/{name}") @@ -248,6 +289,11 @@ def _expected_files(self, script_variant: str) -> list[str]: # Bundled workflow files.append(".specify/workflows/speckit/workflow.yml") files.append(".specify/workflows/workflow-registry.json") + + # Agent context file (if set) + if i.context_file: + files.append(i.context_file) + return sorted(files) def test_complete_file_inventory_sh(self, tmp_path): diff --git a/tests/integrations/test_integration_base_skills.py b/tests/integrations/test_integration_base_skills.py index 72d32278ba..c8c152a84b 100644 --- a/tests/integrations/test_integration_base_skills.py +++ b/tests/integrations/test_integration_base_skills.py @@ -173,6 +173,23 @@ def test_skill_body_has_content(self, tmp_path): body = parts[2].strip() if len(parts) >= 3 else "" assert len(body) > 0, f"{f} has empty body" + def test_plan_references_correct_context_file(self, tmp_path): + """The generated plan skill must reference this integration's context file.""" + i = get_integration(self.KEY) + if not i.context_file: + return + m = IntegrationManifest(self.KEY, tmp_path) + i.setup(tmp_path, m) + plan_file = i.skills_dest(tmp_path) / "speckit-plan" / "SKILL.md" + assert plan_file.exists(), f"Plan skill {plan_file} not created" + content = plan_file.read_text(encoding="utf-8") + assert i.context_file in content, ( + f"Plan skill should reference {i.context_file!r} but it was not found" + ) + assert "__CONTEXT_FILE__" not in content, ( + "Plan skill has unprocessed __CONTEXT_FILE__ placeholder" + ) + def test_all_files_tracked_in_manifest(self, tmp_path): i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) @@ -217,30 +234,34 @@ def test_pre_existing_skills_not_removed(self, tmp_path): assert (foreign_dir / "SKILL.md").exists(), "Foreign skill was removed" - # -- Scripts ---------------------------------------------------------- + # -- Context section --------------------------------------------------- - def test_setup_installs_update_context_scripts(self, tmp_path): + def test_setup_upserts_context_section(self, tmp_path): i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) - scripts_dir = tmp_path / ".specify" / "integrations" / self.KEY / "scripts" - assert scripts_dir.is_dir(), f"Scripts directory not created for {self.KEY}" - assert (scripts_dir / "update-context.sh").exists() - assert (scripts_dir / "update-context.ps1").exists() - - def test_scripts_tracked_in_manifest(self, tmp_path): + if i.context_file: + ctx_path = tmp_path / i.context_file + assert ctx_path.exists(), f"Context file {i.context_file} not created for {self.KEY}" + content = ctx_path.read_text(encoding="utf-8") + assert "" in content + assert "" in content + assert "read the current plan" in content + + def test_teardown_removes_context_section(self, tmp_path): i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) - script_rels = [k for k in m.files if "update-context" in k] - assert len(script_rels) >= 2 - - def test_sh_script_is_executable(self, tmp_path): - i = get_integration(self.KEY) - m = IntegrationManifest(self.KEY, tmp_path) - i.setup(tmp_path, m) - sh = tmp_path / ".specify" / "integrations" / self.KEY / "scripts" / "update-context.sh" - assert os.access(sh, os.X_OK) + m.save() + if i.context_file: + ctx_path = tmp_path / i.context_file + content = ctx_path.read_text(encoding="utf-8") + ctx_path.write_text("# My Rules\n\n" + content + "\n# Footer\n", encoding="utf-8") + i.teardown(tmp_path, m) + remaining = ctx_path.read_text(encoding="utf-8") + assert "" not in remaining + assert "" not in remaining + assert "# My Rules" in remaining # -- CLI auto-promote ------------------------------------------------- @@ -286,6 +307,30 @@ def test_integration_flag_creates_files(self, tmp_path): skills_dir = i.skills_dest(project) assert skills_dir.is_dir(), f"Skills directory {skills_dir} not created" + def test_init_options_includes_context_file(self, tmp_path): + """init-options.json must include context_file for the active integration.""" + import json + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"opts-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", self.KEY, "--script", "sh", + "--no-git", "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + opts = json.loads((project / ".specify" / "init-options.json").read_text()) + i = get_integration(self.KEY) + assert opts.get("context_file") == i.context_file, ( + f"Expected context_file={i.context_file!r}, got {opts.get('context_file')!r}" + ) + # -- IntegrationOption ------------------------------------------------ def test_options_include_skills_flag(self): @@ -316,8 +361,6 @@ def _expected_files(self, script_variant: str) -> list[str]: ".specify/init-options.json", ".specify/integration.json", f".specify/integrations/{self.KEY}.manifest.json", - f".specify/integrations/{self.KEY}/scripts/update-context.ps1", - f".specify/integrations/{self.KEY}/scripts/update-context.sh", ".specify/integrations/speckit.manifest.json", ".specify/memory/constitution.md", ] @@ -328,7 +371,6 @@ def _expected_files(self, script_variant: str) -> list[str]: ".specify/scripts/bash/common.sh", ".specify/scripts/bash/create-new-feature.sh", ".specify/scripts/bash/setup-plan.sh", - ".specify/scripts/bash/update-agent-context.sh", ] else: files += [ @@ -336,11 +378,9 @@ def _expected_files(self, script_variant: str) -> list[str]: ".specify/scripts/powershell/common.ps1", ".specify/scripts/powershell/create-new-feature.ps1", ".specify/scripts/powershell/setup-plan.ps1", - ".specify/scripts/powershell/update-agent-context.ps1", ] # Templates files += [ - ".specify/templates/agent-file-template.md", ".specify/templates/checklist-template.md", ".specify/templates/constitution-template.md", ".specify/templates/plan-template.md", @@ -352,6 +392,9 @@ def _expected_files(self, script_variant: str) -> list[str]: ".specify/workflows/speckit/workflow.yml", ".specify/workflows/workflow-registry.json", ] + # Agent context file (if set) + if i.context_file: + files.append(i.context_file) return sorted(files) def test_complete_file_inventory_sh(self, tmp_path): diff --git a/tests/integrations/test_integration_base_toml.py b/tests/integrations/test_integration_base_toml.py index e80f9abc10..ca66b2123a 100644 --- a/tests/integrations/test_integration_base_toml.py +++ b/tests/integrations/test_integration_base_toml.py @@ -310,6 +310,23 @@ def test_toml_is_valid(self, tmp_path): raise AssertionError(f"{f.name} is not valid TOML: {exc}") from exc assert "prompt" in parsed, f"{f.name} parsed TOML has no 'prompt' key" + def test_plan_references_correct_context_file(self, tmp_path): + """The generated plan command must reference this integration's context file.""" + i = get_integration(self.KEY) + if not i.context_file: + return + m = IntegrationManifest(self.KEY, tmp_path) + i.setup(tmp_path, m) + plan_file = i.commands_dest(tmp_path) / i.command_filename("plan") + assert plan_file.exists(), f"Plan file {plan_file} not created" + content = plan_file.read_text(encoding="utf-8") + assert i.context_file in content, ( + f"Plan command should reference {i.context_file!r} but it was not found in {plan_file.name}" + ) + assert "__CONTEXT_FILE__" not in content, ( + f"Plan command has unprocessed __CONTEXT_FILE__ placeholder in {plan_file.name}" + ) + def test_all_files_tracked_in_manifest(self, tmp_path): i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) @@ -341,37 +358,34 @@ def test_modified_file_survives_uninstall(self, tmp_path): assert modified_file.exists() assert modified_file in skipped - # -- Scripts ---------------------------------------------------------- - - def test_setup_installs_update_context_scripts(self, tmp_path): - i = get_integration(self.KEY) - m = IntegrationManifest(self.KEY, tmp_path) - created = i.setup(tmp_path, m) - scripts_dir = tmp_path / ".specify" / "integrations" / self.KEY / "scripts" - assert scripts_dir.is_dir(), f"Scripts directory not created for {self.KEY}" - assert (scripts_dir / "update-context.sh").exists() - assert (scripts_dir / "update-context.ps1").exists() + # -- Context section --------------------------------------------------- - def test_scripts_tracked_in_manifest(self, tmp_path): + def test_setup_upserts_context_section(self, tmp_path): i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) - script_rels = [k for k in m.files if "update-context" in k] - assert len(script_rels) >= 2 - - def test_sh_script_is_executable(self, tmp_path): + if i.context_file: + ctx_path = tmp_path / i.context_file + assert ctx_path.exists(), f"Context file {i.context_file} not created for {self.KEY}" + content = ctx_path.read_text(encoding="utf-8") + assert "" in content + assert "" in content + assert "read the current plan" in content + + def test_teardown_removes_context_section(self, tmp_path): i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) - sh = ( - tmp_path - / ".specify" - / "integrations" - / self.KEY - / "scripts" - / "update-context.sh" - ) - assert os.access(sh, os.X_OK) + m.save() + if i.context_file: + ctx_path = tmp_path / i.context_file + content = ctx_path.read_text(encoding="utf-8") + ctx_path.write_text("# My Rules\n\n" + content + "\n# Footer\n", encoding="utf-8") + i.teardown(tmp_path, m) + remaining = ctx_path.read_text(encoding="utf-8") + assert "" not in remaining + assert "" not in remaining + assert "# My Rules" in remaining # -- CLI auto-promote ------------------------------------------------- @@ -441,6 +455,30 @@ def test_integration_flag_creates_files(self, tmp_path): commands = sorted(cmd_dir.glob("speckit.*.toml")) assert len(commands) > 0, f"No command files in {cmd_dir}" + def test_init_options_includes_context_file(self, tmp_path): + """init-options.json must include context_file for the active integration.""" + import json + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"opts-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", self.KEY, "--script", "sh", + "--no-git", "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + opts = json.loads((project / ".specify" / "init-options.json").read_text()) + i = get_integration(self.KEY) + assert opts.get("context_file") == i.context_file, ( + f"Expected context_file={i.context_file!r}, got {opts.get('context_file')!r}" + ) + # -- Complete file inventory ------------------------------------------ COMMAND_STEMS = [ @@ -465,10 +503,6 @@ def _expected_files(self, script_variant: str) -> list[str]: for stem in self.COMMAND_STEMS: files.append(f"{cmd_dir}/speckit.{stem}.toml") - # Integration scripts - files.append(f".specify/integrations/{self.KEY}/scripts/update-context.ps1") - files.append(f".specify/integrations/{self.KEY}/scripts/update-context.sh") - # Framework files files.append(".specify/integration.json") files.append(".specify/init-options.json") @@ -481,7 +515,6 @@ def _expected_files(self, script_variant: str) -> list[str]: "common.sh", "create-new-feature.sh", "setup-plan.sh", - "update-agent-context.sh", ]: files.append(f".specify/scripts/bash/{name}") else: @@ -490,12 +523,10 @@ def _expected_files(self, script_variant: str) -> list[str]: "common.ps1", "create-new-feature.ps1", "setup-plan.ps1", - "update-agent-context.ps1", ]: files.append(f".specify/scripts/powershell/{name}") for name in [ - "agent-file-template.md", "checklist-template.md", "constitution-template.md", "plan-template.md", @@ -508,6 +539,11 @@ def _expected_files(self, script_variant: str) -> list[str]: # Bundled workflow files.append(".specify/workflows/speckit/workflow.yml") files.append(".specify/workflows/workflow-registry.json") + + # Agent context file (if set) + if i.context_file: + files.append(i.context_file) + return sorted(files) def test_complete_file_inventory_sh(self, tmp_path): diff --git a/tests/integrations/test_integration_base_yaml.py b/tests/integrations/test_integration_base_yaml.py index e4c31b3c88..08f088576c 100644 --- a/tests/integrations/test_integration_base_yaml.py +++ b/tests/integrations/test_integration_base_yaml.py @@ -189,6 +189,23 @@ def test_yaml_prompt_excludes_frontmatter(self, tmp_path, monkeypatch): assert "scripts:" not in parsed["prompt"] assert "---" not in parsed["prompt"] + def test_plan_references_correct_context_file(self, tmp_path): + """The generated plan command must reference this integration's context file.""" + i = get_integration(self.KEY) + if not i.context_file: + return + m = IntegrationManifest(self.KEY, tmp_path) + i.setup(tmp_path, m) + plan_file = i.commands_dest(tmp_path) / i.command_filename("plan") + assert plan_file.exists(), f"Plan file {plan_file} not created" + content = plan_file.read_text(encoding="utf-8") + assert i.context_file in content, ( + f"Plan command should reference {i.context_file!r} but it was not found in {plan_file.name}" + ) + assert "__CONTEXT_FILE__" not in content, ( + f"Plan command has unprocessed __CONTEXT_FILE__ placeholder in {plan_file.name}" + ) + def test_all_files_tracked_in_manifest(self, tmp_path): i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) @@ -220,37 +237,34 @@ def test_modified_file_survives_uninstall(self, tmp_path): assert modified_file.exists() assert modified_file in skipped - # -- Scripts ---------------------------------------------------------- - - def test_setup_installs_update_context_scripts(self, tmp_path): - i = get_integration(self.KEY) - m = IntegrationManifest(self.KEY, tmp_path) - created = i.setup(tmp_path, m) - scripts_dir = tmp_path / ".specify" / "integrations" / self.KEY / "scripts" - assert scripts_dir.is_dir(), f"Scripts directory not created for {self.KEY}" - assert (scripts_dir / "update-context.sh").exists() - assert (scripts_dir / "update-context.ps1").exists() + # -- Context section --------------------------------------------------- - def test_scripts_tracked_in_manifest(self, tmp_path): + def test_setup_upserts_context_section(self, tmp_path): i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) - script_rels = [k for k in m.files if "update-context" in k] - assert len(script_rels) >= 2 - - def test_sh_script_is_executable(self, tmp_path): + if i.context_file: + ctx_path = tmp_path / i.context_file + assert ctx_path.exists(), f"Context file {i.context_file} not created for {self.KEY}" + content = ctx_path.read_text(encoding="utf-8") + assert "" in content + assert "" in content + assert "read the current plan" in content + + def test_teardown_removes_context_section(self, tmp_path): i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) - sh = ( - tmp_path - / ".specify" - / "integrations" - / self.KEY - / "scripts" - / "update-context.sh" - ) - assert os.access(sh, os.X_OK) + m.save() + if i.context_file: + ctx_path = tmp_path / i.context_file + content = ctx_path.read_text(encoding="utf-8") + ctx_path.write_text("# My Rules\n\n" + content + "\n# Footer\n", encoding="utf-8") + i.teardown(tmp_path, m) + remaining = ctx_path.read_text(encoding="utf-8") + assert "" not in remaining + assert "" not in remaining + assert "# My Rules" in remaining # -- CLI auto-promote ------------------------------------------------- @@ -320,6 +334,30 @@ def test_integration_flag_creates_files(self, tmp_path): commands = sorted(cmd_dir.glob("speckit.*.yaml")) assert len(commands) > 0, f"No command files in {cmd_dir}" + def test_init_options_includes_context_file(self, tmp_path): + """init-options.json must include context_file for the active integration.""" + import json + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / f"opts-{self.KEY}" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", self.KEY, "--script", "sh", + "--no-git", "--ignore-agent-tools", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + opts = json.loads((project / ".specify" / "init-options.json").read_text()) + i = get_integration(self.KEY) + assert opts.get("context_file") == i.context_file, ( + f"Expected context_file={i.context_file!r}, got {opts.get('context_file')!r}" + ) + # -- Complete file inventory ------------------------------------------ COMMAND_STEMS = [ @@ -344,10 +382,6 @@ def _expected_files(self, script_variant: str) -> list[str]: for stem in self.COMMAND_STEMS: files.append(f"{cmd_dir}/speckit.{stem}.yaml") - # Integration scripts - files.append(f".specify/integrations/{self.KEY}/scripts/update-context.ps1") - files.append(f".specify/integrations/{self.KEY}/scripts/update-context.sh") - # Framework files files.append(".specify/integration.json") files.append(".specify/init-options.json") @@ -360,7 +394,6 @@ def _expected_files(self, script_variant: str) -> list[str]: "common.sh", "create-new-feature.sh", "setup-plan.sh", - "update-agent-context.sh", ]: files.append(f".specify/scripts/bash/{name}") else: @@ -369,12 +402,10 @@ def _expected_files(self, script_variant: str) -> list[str]: "common.ps1", "create-new-feature.ps1", "setup-plan.ps1", - "update-agent-context.ps1", ]: files.append(f".specify/scripts/powershell/{name}") for name in [ - "agent-file-template.md", "checklist-template.md", "constitution-template.md", "plan-template.md", @@ -387,6 +418,11 @@ def _expected_files(self, script_variant: str) -> list[str]: # Bundled workflow files.append(".specify/workflows/speckit/workflow.yml") files.append(".specify/workflows/workflow-registry.json") + + # Agent context file (if set) + if i.context_file: + files.append(i.context_file) + return sorted(files) def test_complete_file_inventory_sh(self, tmp_path): diff --git a/tests/integrations/test_integration_catalog.py b/tests/integrations/test_integration_catalog.py index 3d0a14acdc..6d82a6c390 100644 --- a/tests/integrations/test_integration_catalog.py +++ b/tests/integrations/test_integration_catalog.py @@ -285,7 +285,7 @@ def test_clear_cache(self, tmp_path): "commands": [ {"name": "speckit.specify", "file": "templates/speckit.specify.md"}, ], - "scripts": ["update-context.sh"], + "scripts": [], }, } @@ -305,7 +305,7 @@ def test_valid_descriptor(self, tmp_path): assert desc.description == "Integration for My Agent" assert desc.requires_speckit_version == ">=0.6.0" assert len(desc.commands) == 1 - assert desc.scripts == ["update-context.sh"] + assert desc.scripts == [] def test_missing_schema_version(self, tmp_path): data = {**VALID_DESCRIPTOR} diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index d3b01097fc..153983dcf4 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -62,19 +62,17 @@ def test_setup_creates_skill_files(self, tmp_path): assert parsed["disable-model-invocation"] is False assert parsed["metadata"]["source"] == "templates/commands/plan.md" - def test_setup_installs_update_context_scripts(self, tmp_path): + def test_setup_upserts_context_section(self, tmp_path): integration = get_integration("claude") manifest = IntegrationManifest("claude", tmp_path) - created = integration.setup(tmp_path, manifest, script_type="sh") - - scripts_dir = tmp_path / ".specify" / "integrations" / "claude" / "scripts" - assert scripts_dir.is_dir() - assert (scripts_dir / "update-context.sh").exists() - assert (scripts_dir / "update-context.ps1").exists() - - tracked = {path.resolve().relative_to(tmp_path.resolve()).as_posix() for path in created} - assert ".specify/integrations/claude/scripts/update-context.sh" in tracked - assert ".specify/integrations/claude/scripts/update-context.ps1" in tracked + integration.setup(tmp_path, manifest, script_type="sh") + + ctx_path = tmp_path / integration.context_file + assert ctx_path.exists() + content = ctx_path.read_text(encoding="utf-8") + assert "" in content + assert "" in content + assert "read the current plan" in content def test_ai_flag_auto_promotes_and_enables_skills(self, tmp_path): from typer.testing import CliRunner diff --git a/tests/integrations/test_integration_copilot.py b/tests/integrations/test_integration_copilot.py index 34a9d54945..642b1e5300 100644 --- a/tests/integrations/test_integration_copilot.py +++ b/tests/integrations/test_integration_copilot.py @@ -143,7 +143,20 @@ def test_templates_are_processed(self, tmp_path): assert "__AGENT__" not in content, f"{agent_file.name} has unprocessed __AGENT__" assert "{ARGS}" not in content, f"{agent_file.name} has unprocessed {{ARGS}}" assert "\nscripts:\n" not in content - assert "\nagent_scripts:\n" not in content + + def test_plan_references_correct_context_file(self, tmp_path): + """The generated plan command must reference copilot's context file.""" + from specify_cli.integrations.copilot import CopilotIntegration + copilot = CopilotIntegration() + m = IntegrationManifest("copilot", tmp_path) + copilot.setup(tmp_path, m) + plan_file = tmp_path / ".github" / "agents" / "speckit.plan.agent.md" + assert plan_file.exists() + content = plan_file.read_text(encoding="utf-8") + assert copilot.context_file in content, ( + f"Plan command should reference {copilot.context_file!r}" + ) + assert "__CONTEXT_FILE__" not in content def test_complete_file_inventory_sh(self, tmp_path): """Every file produced by specify init --integration copilot --script sh.""" @@ -181,18 +194,15 @@ def test_complete_file_inventory_sh(self, tmp_path): ".github/prompts/speckit.tasks.prompt.md", ".github/prompts/speckit.taskstoissues.prompt.md", ".vscode/settings.json", + ".github/copilot-instructions.md", ".specify/integration.json", ".specify/init-options.json", ".specify/integrations/copilot.manifest.json", ".specify/integrations/speckit.manifest.json", - ".specify/integrations/copilot/scripts/update-context.ps1", - ".specify/integrations/copilot/scripts/update-context.sh", ".specify/scripts/bash/check-prerequisites.sh", ".specify/scripts/bash/common.sh", ".specify/scripts/bash/create-new-feature.sh", ".specify/scripts/bash/setup-plan.sh", - ".specify/scripts/bash/update-agent-context.sh", - ".specify/templates/agent-file-template.md", ".specify/templates/checklist-template.md", ".specify/templates/constitution-template.md", ".specify/templates/plan-template.md", @@ -243,18 +253,15 @@ def test_complete_file_inventory_ps(self, tmp_path): ".github/prompts/speckit.tasks.prompt.md", ".github/prompts/speckit.taskstoissues.prompt.md", ".vscode/settings.json", + ".github/copilot-instructions.md", ".specify/integration.json", ".specify/init-options.json", ".specify/integrations/copilot.manifest.json", ".specify/integrations/speckit.manifest.json", - ".specify/integrations/copilot/scripts/update-context.ps1", - ".specify/integrations/copilot/scripts/update-context.sh", ".specify/scripts/powershell/check-prerequisites.ps1", ".specify/scripts/powershell/common.ps1", ".specify/scripts/powershell/create-new-feature.ps1", ".specify/scripts/powershell/setup-plan.ps1", - ".specify/scripts/powershell/update-agent-context.ps1", - ".specify/templates/agent-file-template.md", ".specify/templates/checklist-template.md", ".specify/templates/constitution-template.md", ".specify/templates/plan-template.md", diff --git a/tests/integrations/test_integration_forge.py b/tests/integrations/test_integration_forge.py index 7affd0d160..613ede91c0 100644 --- a/tests/integrations/test_integration_forge.py +++ b/tests/integrations/test_integration_forge.py @@ -73,19 +73,16 @@ def test_setup_creates_md_files(self, tmp_path): for f in command_files: assert f.name.endswith(".md") - def test_setup_installs_update_scripts(self, tmp_path): + def test_setup_upserts_context_section(self, tmp_path): from specify_cli.integrations.forge import ForgeIntegration forge = ForgeIntegration() m = IntegrationManifest("forge", tmp_path) - created = forge.setup(tmp_path, m) - script_files = [f for f in created if "scripts" in f.parts] - assert len(script_files) > 0 - sh_script = tmp_path / ".specify" / "integrations" / "forge" / "scripts" / "update-context.sh" - ps_script = tmp_path / ".specify" / "integrations" / "forge" / "scripts" / "update-context.ps1" - assert sh_script in created - assert ps_script in created - assert sh_script.exists() - assert ps_script.exists() + forge.setup(tmp_path, m) + ctx_path = tmp_path / forge.context_file + assert ctx_path.exists() + content = ctx_path.read_text(encoding="utf-8") + assert "" in content + assert "" in content def test_all_created_files_tracked_in_manifest(self, tmp_path): from specify_cli.integrations.forge import ForgeIntegration @@ -159,7 +156,20 @@ def test_templates_are_processed(self, tmp_path): assert "$ARGUMENTS" not in content, f"{cmd_file.name} has unprocessed $ARGUMENTS" # Frontmatter sections should be stripped assert "\nscripts:\n" not in content - assert "\nagent_scripts:\n" not in content + + def test_plan_references_correct_context_file(self, tmp_path): + """The generated plan command must reference forge's context file.""" + from specify_cli.integrations.forge import ForgeIntegration + forge = ForgeIntegration() + m = IntegrationManifest("forge", tmp_path) + forge.setup(tmp_path, m) + plan_file = tmp_path / ".forge" / "commands" / "speckit.plan.md" + assert plan_file.exists() + content = plan_file.read_text(encoding="utf-8") + assert forge.context_file in content, ( + f"Plan command should reference {forge.context_file!r}" + ) + assert "__CONTEXT_FILE__" not in content def test_forge_specific_transformations(self, tmp_path): """Test Forge-specific processing: name injection and handoffs stripping.""" diff --git a/tests/integrations/test_integration_generic.py b/tests/integrations/test_integration_generic.py index 74034ef105..8ca32078b5 100644 --- a/tests/integrations/test_integration_generic.py +++ b/tests/integrations/test_integration_generic.py @@ -31,9 +31,9 @@ def test_config_requires_cli_false(self): i = get_integration("generic") assert i.config["requires_cli"] is False - def test_context_file_is_none(self): + def test_context_file_is_agents_md(self): i = get_integration("generic") - assert i.context_file is None + assert i.context_file == "AGENTS.md" # -- Options ---------------------------------------------------------- @@ -158,30 +158,31 @@ def test_different_commands_dirs(self, tmp_path): cmd_files = [f for f in created if "scripts" not in f.parts] assert len(cmd_files) > 0 - # -- Scripts ---------------------------------------------------------- + # -- Context section --------------------------------------------------- - def test_setup_installs_update_context_scripts(self, tmp_path): + def test_setup_upserts_context_section(self, tmp_path): i = get_integration("generic") m = IntegrationManifest("generic", tmp_path) i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"}) - scripts_dir = tmp_path / ".specify" / "integrations" / "generic" / "scripts" - assert scripts_dir.is_dir(), "Scripts directory not created for generic" - assert (scripts_dir / "update-context.sh").exists() - assert (scripts_dir / "update-context.ps1").exists() - - def test_scripts_tracked_in_manifest(self, tmp_path): - i = get_integration("generic") - m = IntegrationManifest("generic", tmp_path) - i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"}) - script_rels = [k for k in m.files if "update-context" in k] - assert len(script_rels) >= 2 - - def test_sh_script_is_executable(self, tmp_path): + if i.context_file: + ctx_path = tmp_path / i.context_file + assert ctx_path.exists() + content = ctx_path.read_text(encoding="utf-8") + assert "" in content + assert "" in content + + def test_plan_references_correct_context_file(self, tmp_path): + """The generated plan command must reference generic's context file.""" i = get_integration("generic") m = IntegrationManifest("generic", tmp_path) i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"}) - sh = tmp_path / ".specify" / "integrations" / "generic" / "scripts" / "update-context.sh" - assert os.access(sh, os.X_OK) + plan_file = tmp_path / ".custom" / "cmds" / "speckit.plan.md" + assert plan_file.exists() + content = plan_file.read_text(encoding="utf-8") + assert i.context_file in content, ( + f"Plan command should reference {i.context_file!r}" + ) + assert "__CONTEXT_FILE__" not in content # -- CLI -------------------------------------------------------------- @@ -198,6 +199,28 @@ def test_cli_generic_without_commands_dir_fails(self, tmp_path): # The integration path validates via setup() assert result.exit_code != 0 + def test_init_options_includes_context_file(self, tmp_path): + """init-options.json must include context_file for the generic integration.""" + import json + from typer.testing import CliRunner + from specify_cli import app + + project = tmp_path / "opts-generic" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = CliRunner().invoke(app, [ + "init", "--here", "--integration", "generic", + "--ai-commands-dir", ".myagent/commands", + "--script", "sh", "--no-git", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + assert result.exit_code == 0 + opts = json.loads((project / ".specify" / "init-options.json").read_text()) + assert opts.get("context_file") == "AGENTS.md" + def test_complete_file_inventory_sh(self, tmp_path): """Every file produced by specify init --integration generic --ai-commands-dir ... --script sh.""" from typer.testing import CliRunner @@ -221,6 +244,7 @@ def test_complete_file_inventory_sh(self, tmp_path): for p in project.rglob("*") if p.is_file() ) expected = sorted([ + "AGENTS.md", ".myagent/commands/speckit.analyze.md", ".myagent/commands/speckit.checklist.md", ".myagent/commands/speckit.clarify.md", @@ -233,16 +257,12 @@ def test_complete_file_inventory_sh(self, tmp_path): ".specify/init-options.json", ".specify/integration.json", ".specify/integrations/generic.manifest.json", - ".specify/integrations/generic/scripts/update-context.ps1", - ".specify/integrations/generic/scripts/update-context.sh", ".specify/integrations/speckit.manifest.json", ".specify/memory/constitution.md", ".specify/scripts/bash/check-prerequisites.sh", ".specify/scripts/bash/common.sh", ".specify/scripts/bash/create-new-feature.sh", ".specify/scripts/bash/setup-plan.sh", - ".specify/scripts/bash/update-agent-context.sh", - ".specify/templates/agent-file-template.md", ".specify/templates/checklist-template.md", ".specify/templates/constitution-template.md", ".specify/templates/plan-template.md", @@ -279,6 +299,7 @@ def test_complete_file_inventory_ps(self, tmp_path): for p in project.rglob("*") if p.is_file() ) expected = sorted([ + "AGENTS.md", ".myagent/commands/speckit.analyze.md", ".myagent/commands/speckit.checklist.md", ".myagent/commands/speckit.clarify.md", @@ -291,16 +312,12 @@ def test_complete_file_inventory_ps(self, tmp_path): ".specify/init-options.json", ".specify/integration.json", ".specify/integrations/generic.manifest.json", - ".specify/integrations/generic/scripts/update-context.ps1", - ".specify/integrations/generic/scripts/update-context.sh", ".specify/integrations/speckit.manifest.json", ".specify/memory/constitution.md", ".specify/scripts/powershell/check-prerequisites.ps1", ".specify/scripts/powershell/common.ps1", ".specify/scripts/powershell/create-new-feature.ps1", ".specify/scripts/powershell/setup-plan.ps1", - ".specify/scripts/powershell/update-agent-context.ps1", - ".specify/templates/agent-file-template.md", ".specify/templates/checklist-template.md", ".specify/templates/constitution-template.md", ".specify/templates/plan-template.md", diff --git a/tests/test_agent_config_consistency.py b/tests/test_agent_config_consistency.py index 9cfe1ddbc9..75e80fdf33 100644 --- a/tests/test_agent_config_consistency.py +++ b/tests/test_agent_config_consistency.py @@ -1,6 +1,5 @@ """Consistency checks for agent configuration across runtime surfaces.""" -import re from pathlib import Path from specify_cli import AGENT_CONFIG, AI_ASSISTANT_ALIASES, AI_ASSISTANT_HELP @@ -61,20 +60,6 @@ def test_devcontainer_kiro_installer_uses_pinned_checksum(self): assert "sha256sum -c -" in post_create_text assert "KIRO_SKIP_KIRO_INSTALLER_VERIFY" not in post_create_text - def test_agent_context_scripts_use_kiro_cli(self): - """Agent context scripts should advertise kiro-cli and not legacy q agent key.""" - bash_text = ( - REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh" - ).read_text(encoding="utf-8") - pwsh_text = ( - REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1" - ).read_text(encoding="utf-8") - - assert "kiro-cli" in bash_text - assert "kiro-cli" in pwsh_text - assert "Amazon Q Developer CLI" not in bash_text - assert "Amazon Q Developer CLI" not in pwsh_text - # --- Tabnine CLI consistency checks --- def test_runtime_config_includes_tabnine(self): @@ -96,20 +81,6 @@ def test_extension_registrar_includes_tabnine(self): assert cfg["args"] == "{{args}}" assert cfg["extension"] == ".toml" - def test_agent_context_scripts_include_tabnine(self): - """Agent context scripts should support tabnine agent type.""" - bash_text = ( - REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh" - ).read_text(encoding="utf-8") - pwsh_text = ( - REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1" - ).read_text(encoding="utf-8") - - assert "tabnine" in bash_text - assert "TABNINE_FILE" in bash_text - assert "tabnine" in pwsh_text - assert "TABNINE_FILE" in pwsh_text - def test_ai_help_includes_tabnine(self): """CLI help text for --ai should include tabnine.""" assert "tabnine" in AI_ASSISTANT_HELP @@ -132,18 +103,6 @@ def test_kimi_in_extension_registrar(self): assert kimi_cfg["dir"] == ".kimi/skills" assert kimi_cfg["extension"] == "/SKILL.md" - def test_kimi_in_powershell_validate_set(self): - """PowerShell update-agent-context script should include 'kimi' in ValidateSet.""" - ps_text = ( - REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1" - ).read_text(encoding="utf-8") - - validate_set_match = re.search(r"\[ValidateSet\(([^)]*)\)\]", ps_text) - assert validate_set_match is not None - validate_set_values = re.findall(r"'([^']+)'", validate_set_match.group(1)) - - assert "kimi" in validate_set_values - def test_ai_help_includes_kimi(self): """CLI help text for --ai should include kimi.""" assert "kimi" in AI_ASSISTANT_HELP @@ -168,32 +127,6 @@ def test_trae_in_extension_registrar(self): assert trae_cfg["args"] == "$ARGUMENTS" assert trae_cfg["extension"] == "/SKILL.md" - def test_trae_in_agent_context_scripts(self): - """Agent context scripts should support trae agent type.""" - bash_text = ( - REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh" - ).read_text(encoding="utf-8") - pwsh_text = ( - REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1" - ).read_text(encoding="utf-8") - - assert "trae" in bash_text - assert "TRAE_FILE" in bash_text - assert "trae" in pwsh_text - assert "TRAE_FILE" in pwsh_text - - def test_trae_in_powershell_validate_set(self): - """PowerShell update-agent-context script should include 'trae' in ValidateSet.""" - ps_text = ( - REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1" - ).read_text(encoding="utf-8") - - validate_set_match = re.search(r"\[ValidateSet\(([^)]*)\)\]", ps_text) - assert validate_set_match is not None - validate_set_values = re.findall(r"'([^']+)'", validate_set_match.group(1)) - - assert "trae" in validate_set_values - def test_ai_help_includes_trae(self): """CLI help text for --ai should include trae.""" assert "trae" in AI_ASSISTANT_HELP @@ -219,32 +152,6 @@ def test_pi_in_extension_registrar(self): assert pi_cfg["args"] == "$ARGUMENTS" assert pi_cfg["extension"] == ".md" - def test_pi_in_powershell_validate_set(self): - """PowerShell update-agent-context script should include 'pi' in ValidateSet.""" - ps_text = ( - REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1" - ).read_text(encoding="utf-8") - - validate_set_match = re.search(r"\[ValidateSet\(([^)]*)\)\]", ps_text) - assert validate_set_match is not None - validate_set_values = re.findall(r"'([^']+)'", validate_set_match.group(1)) - - assert "pi" in validate_set_values - - def test_agent_context_scripts_include_pi(self): - """Agent context scripts should support pi agent type.""" - bash_text = ( - REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh" - ).read_text(encoding="utf-8") - pwsh_text = ( - REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1" - ).read_text(encoding="utf-8") - - assert "pi" in bash_text - assert "Pi Coding Agent" in bash_text - assert "pi" in pwsh_text - assert "Pi Coding Agent" in pwsh_text - def test_ai_help_includes_pi(self): """CLI help text for --ai should include pi.""" assert "pi" in AI_ASSISTANT_HELP @@ -267,20 +174,6 @@ def test_iflow_in_extension_registrar(self): assert cfg["iflow"]["format"] == "markdown" assert cfg["iflow"]["args"] == "$ARGUMENTS" - def test_iflow_in_agent_context_scripts(self): - """Agent context scripts should support iflow agent type.""" - bash_text = ( - REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh" - ).read_text(encoding="utf-8") - pwsh_text = ( - REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1" - ).read_text(encoding="utf-8") - - assert "iflow" in bash_text - assert "IFLOW_FILE" in bash_text - assert "iflow" in pwsh_text - assert "IFLOW_FILE" in pwsh_text - def test_ai_help_includes_iflow(self): """CLI help text for --ai should include iflow.""" assert "iflow" in AI_ASSISTANT_HELP @@ -303,18 +196,6 @@ def test_goose_in_extension_registrar(self): assert cfg["goose"]["format"] == "yaml" assert cfg["goose"]["args"] == "{{args}}" - def test_goose_in_agent_context_scripts(self): - """Agent context scripts should support goose agent type.""" - bash_text = ( - REPO_ROOT / "scripts" / "bash" / "update-agent-context.sh" - ).read_text(encoding="utf-8") - pwsh_text = ( - REPO_ROOT / "scripts" / "powershell" / "update-agent-context.ps1" - ).read_text(encoding="utf-8") - - assert "goose" in bash_text - assert "goose" in pwsh_text - def test_ai_help_includes_goose(self): """CLI help text for --ai should include goose.""" assert "goose" in AI_ASSISTANT_HELP diff --git a/tests/test_cursor_frontmatter.py b/tests/test_cursor_frontmatter.py deleted file mode 100644 index 9f8c31ce10..0000000000 --- a/tests/test_cursor_frontmatter.py +++ /dev/null @@ -1,266 +0,0 @@ -""" -Tests for Cursor .mdc frontmatter generation (issue #669). - -Verifies that update-agent-context.sh properly prepends YAML frontmatter -to .mdc files so that Cursor IDE auto-includes the rules. -""" - -import os -import shutil -import subprocess -import textwrap - -import pytest - -from tests.conftest import requires_bash - -SCRIPT_PATH = os.path.join( - os.path.dirname(__file__), - os.pardir, - "scripts", - "bash", - "update-agent-context.sh", -) - -EXPECTED_FRONTMATTER_LINES = [ - "---", - "description: Project Development Guidelines", - 'globs: ["**/*"]', - "alwaysApply: true", - "---", -] - -requires_git = pytest.mark.skipif( - shutil.which("git") is None, - reason="git is not installed", -) - - -class TestScriptFrontmatterPattern: - """Static analysis — no git required.""" - - def test_create_new_has_mdc_frontmatter_logic(self): - """create_new_agent_file() must contain .mdc frontmatter logic.""" - with open(SCRIPT_PATH, encoding="utf-8") as f: - content = f.read() - assert 'if [[ "$target_file" == *.mdc ]]' in content - assert "alwaysApply: true" in content - - def test_update_existing_has_mdc_frontmatter_logic(self): - """update_existing_agent_file() must also handle .mdc frontmatter.""" - with open(SCRIPT_PATH, encoding="utf-8") as f: - content = f.read() - # There should be two occurrences of the .mdc check — one per function - occurrences = content.count('if [[ "$target_file" == *.mdc ]]') - assert occurrences >= 2, ( - f"Expected at least 2 .mdc frontmatter checks, found {occurrences}" - ) - - def test_powershell_script_has_mdc_frontmatter_logic(self): - """PowerShell script must also handle .mdc frontmatter.""" - ps_path = os.path.join( - os.path.dirname(__file__), - os.pardir, - "scripts", - "powershell", - "update-agent-context.ps1", - ) - with open(ps_path, encoding="utf-8") as f: - content = f.read() - assert "alwaysApply: true" in content - occurrences = content.count(r"\.mdc$") - assert occurrences >= 2, ( - f"Expected at least 2 .mdc frontmatter checks in PS script, found {occurrences}" - ) - - -@requires_git -@requires_bash -class TestCursorFrontmatterIntegration: - """Integration tests using a real git repo.""" - - @pytest.fixture - def git_repo(self, tmp_path): - """Create a minimal git repo with the spec-kit structure.""" - repo = tmp_path / "repo" - repo.mkdir() - - # Init git repo - subprocess.run( - ["git", "init"], cwd=str(repo), capture_output=True, check=True - ) - subprocess.run( - ["git", "config", "user.email", "test@test.com"], - cwd=str(repo), - capture_output=True, - check=True, - ) - subprocess.run( - ["git", "config", "user.name", "Test"], - cwd=str(repo), - capture_output=True, - check=True, - ) - - # Create .specify dir with config - specify_dir = repo / ".specify" - specify_dir.mkdir() - (specify_dir / "config.yaml").write_text( - textwrap.dedent("""\ - project_type: webapp - language: python - framework: fastapi - database: N/A - """) - ) - - # Create template - templates_dir = specify_dir / "templates" - templates_dir.mkdir() - (templates_dir / "agent-file-template.md").write_text( - "# [PROJECT NAME] Development Guidelines\n\n" - "Auto-generated from all feature plans. Last updated: [DATE]\n\n" - "## Active Technologies\n\n" - "[EXTRACTED FROM ALL PLAN.MD FILES]\n\n" - "## Project Structure\n\n" - "[ACTUAL STRUCTURE FROM PLANS]\n\n" - "## Development Commands\n\n" - "[ONLY COMMANDS FOR ACTIVE TECHNOLOGIES]\n\n" - "## Coding Conventions\n\n" - "[LANGUAGE-SPECIFIC, ONLY FOR LANGUAGES IN USE]\n\n" - "## Recent Changes\n\n" - "[LAST 3 FEATURES AND WHAT THEY ADDED]\n" - ) - - # Create initial commit - subprocess.run( - ["git", "add", "-A"], cwd=str(repo), capture_output=True, check=True - ) - subprocess.run( - ["git", "commit", "-m", "init"], - cwd=str(repo), - capture_output=True, - check=True, - ) - - # Create a feature branch so CURRENT_BRANCH detection works - subprocess.run( - ["git", "checkout", "-b", "001-test-feature"], - cwd=str(repo), - capture_output=True, - check=True, - ) - - # Create a spec so the script detects the feature - spec_dir = repo / "specs" / "001-test-feature" - spec_dir.mkdir(parents=True) - (spec_dir / "plan.md").write_text( - "# Test Feature Plan\n\n" - "## Technology Stack\n\n" - "- Language: Python\n" - "- Framework: FastAPI\n" - ) - - return repo - - def _run_update(self, repo, agent_type="cursor-agent"): - """Run update-agent-context.sh for a specific agent type.""" - script = os.path.abspath(SCRIPT_PATH) - result = subprocess.run( - ["bash", script, agent_type], - cwd=str(repo), - capture_output=True, - text=True, - timeout=30, - ) - return result - - def test_new_mdc_file_has_frontmatter(self, git_repo): - """Creating a new .mdc file must include YAML frontmatter.""" - result = self._run_update(git_repo) - assert result.returncode == 0, f"Script failed: {result.stderr}" - - mdc_file = git_repo / ".cursor" / "rules" / "specify-rules.mdc" - assert mdc_file.exists(), "Cursor .mdc file was not created" - - content = mdc_file.read_text() - lines = content.splitlines() - - # First line must be the opening --- - assert lines[0] == "---", f"Expected frontmatter start, got: {lines[0]}" - - # Check all frontmatter lines are present - for expected in EXPECTED_FRONTMATTER_LINES: - assert expected in content, f"Missing frontmatter line: {expected}" - - # Content after frontmatter should be the template content - assert "Development Guidelines" in content - - def test_existing_mdc_without_frontmatter_gets_it_added(self, git_repo): - """Updating an existing .mdc file that lacks frontmatter must add it.""" - # First, create the file WITHOUT frontmatter (simulating pre-fix state) - cursor_dir = git_repo / ".cursor" / "rules" - cursor_dir.mkdir(parents=True, exist_ok=True) - mdc_file = cursor_dir / "specify-rules.mdc" - mdc_file.write_text( - "# repo Development Guidelines\n\n" - "Auto-generated from all feature plans. Last updated: 2025-01-01\n\n" - "## Active Technologies\n\n" - "- Python + FastAPI (main)\n\n" - "## Recent Changes\n\n" - "- main: Added Python + FastAPI\n" - ) - - result = self._run_update(git_repo) - assert result.returncode == 0, f"Script failed: {result.stderr}" - - content = mdc_file.read_text() - lines = content.splitlines() - - assert lines[0] == "---", f"Expected frontmatter start, got: {lines[0]}" - for expected in EXPECTED_FRONTMATTER_LINES: - assert expected in content, f"Missing frontmatter line: {expected}" - - def test_existing_mdc_with_frontmatter_not_duplicated(self, git_repo): - """Updating an .mdc file that already has frontmatter must not duplicate it.""" - cursor_dir = git_repo / ".cursor" / "rules" - cursor_dir.mkdir(parents=True, exist_ok=True) - mdc_file = cursor_dir / "specify-rules.mdc" - - frontmatter = ( - "---\n" - "description: Project Development Guidelines\n" - 'globs: ["**/*"]\n' - "alwaysApply: true\n" - "---\n\n" - ) - body = ( - "# repo Development Guidelines\n\n" - "Auto-generated from all feature plans. Last updated: 2025-01-01\n\n" - "## Active Technologies\n\n" - "- Python + FastAPI (main)\n\n" - "## Recent Changes\n\n" - "- main: Added Python + FastAPI\n" - ) - mdc_file.write_text(frontmatter + body) - - result = self._run_update(git_repo) - assert result.returncode == 0, f"Script failed: {result.stderr}" - - content = mdc_file.read_text() - # Count occurrences of the frontmatter delimiter - assert content.count("alwaysApply: true") == 1, ( - "Frontmatter was duplicated" - ) - - def test_non_mdc_file_has_no_frontmatter(self, git_repo): - """Non-.mdc agent files (e.g., Claude) must NOT get frontmatter.""" - result = self._run_update(git_repo, agent_type="claude") - assert result.returncode == 0, f"Script failed: {result.stderr}" - - claude_file = git_repo / ".claude" / "CLAUDE.md" - if claude_file.exists(): - content = claude_file.read_text() - assert not content.startswith("---"), ( - "Non-mdc file should not have frontmatter" - ) diff --git a/tests/test_extension_skills.py b/tests/test_extension_skills.py index c9d13382ab..89e8b4a8b8 100644 --- a/tests/test_extension_skills.py +++ b/tests/test_extension_skills.py @@ -396,11 +396,8 @@ def test_skill_registration_resolves_script_placeholders(self, project_dir, temp "description: Scripted plan command\n" "scripts:\n" " sh: ../../scripts/bash/setup-plan.sh --json \"{ARGS}\"\n" - "agent_scripts:\n" - " sh: ../../scripts/bash/update-agent-context.sh __AGENT__\n" "---\n\n" "Run {SCRIPT}\n" - "Then {AGENT_SCRIPT}\n" "Review templates/checklist.md and memory/constitution.md for __AGENT__.\n" ) @@ -409,11 +406,9 @@ def test_skill_registration_resolves_script_placeholders(self, project_dir, temp content = (skills_dir / "speckit-scripted-ext-plan" / "SKILL.md").read_text() assert "{SCRIPT}" not in content - assert "{AGENT_SCRIPT}" not in content assert "{ARGS}" not in content assert "__AGENT__" not in content assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content - assert ".specify/scripts/bash/update-agent-context.sh claude" in content assert ".specify/templates/checklist.md" in content assert ".specify/memory/constitution.md" in content diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 460404d597..5379178afe 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -1334,13 +1334,9 @@ def test_codex_skill_registration_resolves_script_placeholders(self, project_dir scripts: sh: ../../scripts/bash/setup-plan.sh --json "{ARGS}" ps: ../../scripts/powershell/setup-plan.ps1 -Json -agent_scripts: - sh: ../../scripts/bash/update-agent-context.sh __AGENT__ - ps: ../../scripts/powershell/update-agent-context.ps1 -AgentType __AGENT__ --- Run {SCRIPT} -Then {AGENT_SCRIPT} Agent __AGENT__ """ ) @@ -1361,11 +1357,9 @@ def test_codex_skill_registration_resolves_script_placeholders(self, project_dir content = skill_file.read_text() assert "{SCRIPT}" not in content - assert "{AGENT_SCRIPT}" not in content assert "__AGENT__" not in content assert "{ARGS}" not in content assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content - assert ".specify/scripts/bash/update-agent-context.sh codex" in content def test_codex_skill_alias_frontmatter_matches_alias_name(self, project_dir, temp_dir): """Codex alias skills should render their own matching `name:` frontmatter.""" @@ -1451,13 +1445,9 @@ def test_codex_skill_registration_uses_fallback_script_variant_without_init_opti scripts: sh: ../../scripts/bash/setup-plan.sh --json "{ARGS}" ps: ../../scripts/powershell/setup-plan.ps1 -Json -agent_scripts: - sh: ../../scripts/bash/update-agent-context.sh __AGENT__ - ps: ../../scripts/powershell/update-agent-context.ps1 __AGENT__ --- Run {SCRIPT} -Then {AGENT_SCRIPT} """ ) @@ -1474,13 +1464,10 @@ def test_codex_skill_registration_uses_fallback_script_variant_without_init_opti content = skill_file.read_text() assert "{SCRIPT}" not in content - assert "{AGENT_SCRIPT}" not in content if platform.system().lower().startswith("win"): assert ".specify/scripts/powershell/setup-plan.ps1 -Json" in content - assert ".specify/scripts/powershell/update-agent-context.ps1 codex" in content else: assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content - assert ".specify/scripts/bash/update-agent-context.sh codex" in content def test_codex_skill_registration_handles_non_dict_init_options( self, project_dir, temp_dir @@ -1577,13 +1564,9 @@ def test_codex_skill_registration_fallback_prefers_powershell_on_windows( scripts: sh: ../../scripts/bash/setup-plan.sh --json "{ARGS}" ps: ../../scripts/powershell/setup-plan.ps1 -Json -agent_scripts: - sh: ../../scripts/bash/update-agent-context.sh __AGENT__ - ps: ../../scripts/powershell/update-agent-context.ps1 -AgentType __AGENT__ --- Run {SCRIPT} -Then {AGENT_SCRIPT} """ ) @@ -1599,7 +1582,6 @@ def test_codex_skill_registration_fallback_prefers_powershell_on_windows( content = skill_file.read_text() assert ".specify/scripts/powershell/setup-plan.ps1 -Json" in content - assert ".specify/scripts/powershell/update-agent-context.ps1 -AgentType codex" in content assert ".specify/scripts/bash/setup-plan.sh" not in content def test_register_commands_for_copilot(self, extension_dir, project_dir): diff --git a/tests/test_presets.py b/tests/test_presets.py index b883d554b0..cd65c45c5b 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -1648,7 +1648,6 @@ def test_url_cache_expired(self, project_dir): "tasks-template", "checklist-template", "constitution-template", - "agent-file-template", ] @@ -2919,7 +2918,6 @@ def test_lean_commands_have_no_scripts(self): content = cmd_path.read_text() frontmatter, _ = CommandRegistrar.parse_frontmatter(content) assert "scripts" not in frontmatter, f"{name} should not have scripts in frontmatter" - assert "agent_scripts" not in frontmatter, f"{name} should not have agent_scripts in frontmatter" def test_lean_commands_have_no_hooks(self): """Verify lean commands do not contain extension hook boilerplate.""" From baf9a61abb48bd4610e1daf0a3cc5f309a0ad8c3 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 17 Apr 2026 10:09:41 -0500 Subject: [PATCH 02/11] fix: search for end marker after start marker in context section methods Address Copilot review: content.find(CONTEXT_MARKER_END) searched from the start of the file rather than after the located start marker. If the file contained a stray end marker before the start marker, the wrong slice could be replaced. Now both upsert_context_section() and remove_context_section() pass start_idx as the second argument to find() and validate end_idx > start_idx before performing the replacement. --- src/specify_cli/integrations/base.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 86cf08af7e..fc7ce616e5 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -431,9 +431,12 @@ def upsert_context_section( if ctx_path.exists(): content = ctx_path.read_text(encoding="utf-8") start_idx = content.find(self.CONTEXT_MARKER_START) - end_idx = content.find(self.CONTEXT_MARKER_END) + end_idx = content.find( + self.CONTEXT_MARKER_END, + start_idx if start_idx != -1 else 0, + ) - if start_idx != -1 and end_idx != -1: + if start_idx != -1 and end_idx != -1 and end_idx > start_idx: # Replace existing section (include the end marker + newline) end_of_marker = end_idx + len(self.CONTEXT_MARKER_END) if end_of_marker < len(content) and content[end_of_marker] == "\n": @@ -468,9 +471,12 @@ def remove_context_section(self, project_root: Path) -> bool: content = ctx_path.read_text(encoding="utf-8") start_idx = content.find(self.CONTEXT_MARKER_START) - end_idx = content.find(self.CONTEXT_MARKER_END) + end_idx = content.find( + self.CONTEXT_MARKER_END, + start_idx if start_idx != -1 else 0, + ) - if start_idx == -1 or end_idx == -1: + if start_idx == -1 or end_idx == -1 or end_idx <= start_idx: return False end_of_marker = end_idx + len(self.CONTEXT_MARKER_END) From 9bb5ba36182e1aa55c48c8264245aff2c51b15dc Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 17 Apr 2026 10:24:37 -0500 Subject: [PATCH 03/11] fix: address Copilot review feedback on context section handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Fix grammar in _build_context_section() directive text — add commas for a complete sentence. 2. Resolve __CONTEXT_FILE__ in resolve_skill_placeholders() — skills generated via extensions/presets for codex/kimi now replace the placeholder using the context_file value from init-options.json. 3. Handle Cursor .mdc frontmatter — when creating a new .mdc context file, prepend alwaysApply: true YAML frontmatter so Cursor auto-loads the rules. 4. Fix empty-file leading newline — when the context file exists but is empty, write the section directly instead of prepending a blank line. --- src/specify_cli/agents.py | 5 +++++ src/specify_cli/integrations/base.py | 17 ++++++++++++----- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 31dc2e99f0..7be0b3895c 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -362,6 +362,11 @@ def resolve_skill_placeholders( body = body.replace("{SCRIPT}", script_command) body = body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", agent_name) + + # Resolve __CONTEXT_FILE__ from init-options + context_file = init_opts.get("context_file", "") + body = body.replace("__CONTEXT_FILE__", context_file) + return CommandRegistrar.rewrite_project_relative_paths(body) def _convert_argument_placeholder( diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index fc7ce616e5..7d0bf65fdd 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -397,7 +397,7 @@ def _build_context_section(plan_path: str = "") -> str: """ lines = [ "For additional context about technologies to be used, project structure,", - "shell commands and other important information read the current plan", + "shell commands, and other important information, read the current plan", ] if plan_path: lines.append(f"at {plan_path}") @@ -444,12 +444,19 @@ def upsert_context_section( new_content = content[:start_idx] + section + content[end_of_marker:] else: # Markers not found — append - if content and not content.endswith("\n"): - content += "\n" - new_content = content + "\n" + section + if content: + if not content.endswith("\n"): + content += "\n" + new_content = content + "\n" + section + else: + new_content = section else: ctx_path.parent.mkdir(parents=True, exist_ok=True) - new_content = section + # Cursor .mdc files require YAML frontmatter to be loaded + if ctx_path.suffix == ".mdc": + new_content = "---\nalwaysApply: true\n---\n\n" + section + else: + new_content = section normalized = new_content.replace("\r\n", "\n") ctx_path.write_bytes(normalized.encode("utf-8")) From adbd1a96f88ad7bba1682ba7d528b5341b5f286e Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 17 Apr 2026 10:34:52 -0500 Subject: [PATCH 04/11] fix: address second round of Copilot review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Ensure .mdc frontmatter on existing files — upsert_context_section() now checks for missing YAML frontmatter on .mdc files during updates (not just creation), so pre-existing Cursor files get alwaysApply. 2. Guard against context_file=None — use 'or ""' instead of a default arg so explicit null values in init-options.json don't cause a TypeError in str.replace(). 3. Clean up .mdc files on removal — remove_context_section() treats files containing only the Speckit-generated frontmatter block as empty, deleting them rather than leaving orphaned frontmatter. --- src/specify_cli/agents.py | 2 +- src/specify_cli/integrations/base.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 7be0b3895c..1a0e5a8317 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -364,7 +364,7 @@ def resolve_skill_placeholders( body = body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", agent_name) # Resolve __CONTEXT_FILE__ from init-options - context_file = init_opts.get("context_file", "") + context_file = init_opts.get("context_file") or "" body = body.replace("__CONTEXT_FILE__", context_file) return CommandRegistrar.rewrite_project_relative_paths(body) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 7d0bf65fdd..e5ae6211c1 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -450,6 +450,10 @@ def upsert_context_section( new_content = content + "\n" + section else: new_content = section + + # Ensure .mdc files have required YAML frontmatter + if ctx_path.suffix == ".mdc" and not new_content.startswith("---\n"): + new_content = "---\nalwaysApply: true\n---\n\n" + new_content else: ctx_path.parent.mkdir(parents=True, exist_ok=True) # Cursor .mdc files require YAML frontmatter to be loaded @@ -497,6 +501,13 @@ def remove_context_section(self, project_root: Path) -> bool: new_content = content[:start_idx] + content[end_of_marker:] + # For .mdc files, also strip Speckit-generated frontmatter + if ctx_path.suffix == ".mdc": + stripped = new_content.strip() + if stripped in ("", "---\nalwaysApply: true\n---"): + ctx_path.unlink() + return True + if not new_content.strip(): ctx_path.unlink() else: From 2dfefaf93cccba5aaa13044af21d2a852e253313 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 17 Apr 2026 10:58:46 -0500 Subject: [PATCH 05/11] fix: address third round of Copilot review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. CRLF-safe .mdc frontmatter check — use lstrip().startswith('---') instead of startswith('---\n') so CRLF files don't get duplicate frontmatter. 2. CRLF-safe .mdc removal check — normalize line endings before comparing against the sentinel frontmatter string. 3. Call remove_context_section() during integration_uninstall() — the manifest-only uninstall was leaving the managed SPECKIT markers behind in the agent context file. 4. Fix stale docstring — remove 'agent_scripts' mention from test_lean_commands_have_no_scripts(). --- src/specify_cli/__init__.py | 4 ++++ src/specify_cli/integrations/base.py | 10 ++++++---- tests/test_presets.py | 2 +- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 50badd830d..e0d8f6f10b 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -2077,6 +2077,10 @@ def integration_uninstall( removed, skipped = manifest.uninstall(project_root, force=force) + # Remove managed context section from the agent context file + if integration: + integration.remove_context_section(project_root) + _remove_integration_json(project_root) # Update init-options.json to clear the integration diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index e5ae6211c1..f31bc90b70 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -452,7 +452,7 @@ def upsert_context_section( new_content = section # Ensure .mdc files have required YAML frontmatter - if ctx_path.suffix == ".mdc" and not new_content.startswith("---\n"): + if ctx_path.suffix == ".mdc" and not new_content.lstrip().startswith("---"): new_content = "---\nalwaysApply: true\n---\n\n" + new_content else: ctx_path.parent.mkdir(parents=True, exist_ok=True) @@ -501,17 +501,19 @@ def remove_context_section(self, project_root: Path) -> bool: new_content = content[:start_idx] + content[end_of_marker:] + # Normalize line endings before comparisons + normalized = new_content.replace("\r\n", "\n").replace("\r", "\n") + # For .mdc files, also strip Speckit-generated frontmatter if ctx_path.suffix == ".mdc": - stripped = new_content.strip() + stripped = normalized.strip() if stripped in ("", "---\nalwaysApply: true\n---"): ctx_path.unlink() return True - if not new_content.strip(): + if not normalized.strip(): ctx_path.unlink() else: - normalized = new_content.replace("\r\n", "\n") ctx_path.write_bytes(normalized.encode("utf-8")) return True diff --git a/tests/test_presets.py b/tests/test_presets.py index cd65c45c5b..35c19bdd7f 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -2910,7 +2910,7 @@ def test_lean_command_files_exist(self): assert tmpl_path.exists(), f"Missing command file: {tmpl['file']}" def test_lean_commands_have_no_scripts(self): - """Verify lean commands have no scripts or agent_scripts in frontmatter.""" + """Verify lean commands have no scripts in frontmatter.""" from specify_cli.agents import CommandRegistrar for name in LEAN_COMMAND_NAMES: From 14fd312b8ef1068576ec48bca75428b6521dcb47 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:12:45 -0500 Subject: [PATCH 06/11] fix: address fourth round of Copilot review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Remove unused script_type parameter from _write_integration_json() and all 3 call sites — the parameter was no longer referenced after the update-context script removal. 2. Fix _build_context_section() docstring — correct example path from '.specify/plans/plan.md' to 'specs//plan.md'. 3. Improve .mdc frontmatter-only detection in remove_context_section() — use regex to match any YAML frontmatter block (not just the exact Speckit-generated one), so .mdc files with additional frontmatter keys are also cleaned up when no body content remains. --- src/specify_cli/__init__.py | 7 +++---- src/specify_cli/integrations/base.py | 10 +++++++--- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index e0d8f6f10b..274db57bcc 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1734,7 +1734,6 @@ def _read_integration_json(project_root: Path) -> dict[str, Any]: def _write_integration_json( project_root: Path, integration_key: str, - script_type: str, ) -> None: """Write ``.specify/integration.json`` for *integration_key*.""" dest = project_root / INTEGRATION_JSON @@ -1929,7 +1928,7 @@ def integration_install( raw_options=integration_options, ) manifest.save() - _write_integration_json(project_root, integration.key, selected_script) + _write_integration_json(project_root, integration.key) _update_init_options_for_integration(project_root, integration, script_type=selected_script) except Exception as e: @@ -2212,7 +2211,7 @@ def integration_switch( raw_options=integration_options, ) manifest.save() - _write_integration_json(project_root, target_integration.key, selected_script) + _write_integration_json(project_root, target_integration.key) _update_init_options_for_integration(project_root, target_integration, script_type=selected_script) except Exception as e: @@ -2320,7 +2319,7 @@ def integration_upgrade( raw_options=integration_options, ) new_manifest.save() - _write_integration_json(project_root, key, selected_script) + _write_integration_json(project_root, key) _update_init_options_for_integration(project_root, integration, script_type=selected_script) except Exception as exc: # Don't teardown — setup overwrites in-place, so teardown would diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index f31bc90b70..02632d4da9 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -392,7 +392,7 @@ def _build_context_section(plan_path: str = "") -> str: """Build the content for the managed section between markers. *plan_path* is the project-relative path to the current plan - (e.g. ``".specify/plans/plan.md"``). When empty, the section + (e.g. ``"specs//plan.md"``). When empty, the section contains only the generic directive without a concrete path. """ lines = [ @@ -506,8 +506,12 @@ def remove_context_section(self, project_root: Path) -> bool: # For .mdc files, also strip Speckit-generated frontmatter if ctx_path.suffix == ".mdc": - stripped = normalized.strip() - if stripped in ("", "---\nalwaysApply: true\n---"): + import re + # Treat as empty if only YAML frontmatter remains (no body content) + frontmatter_only = re.match( + r"^---\n.*?\n---\s*$", normalized, re.DOTALL + ) + if not normalized.strip() or frontmatter_only: ctx_path.unlink() return True From c1ca2a0bfad84191b8490f780e9799905e3a0389 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:28:33 -0500 Subject: [PATCH 07/11] fix: handle corrupted markers and parse .mdc frontmatter robustly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Handle partial/corrupted markers in upsert_context_section() — if only the START marker exists (no END), replace from START through EOF. If only the END marker exists, replace from BOF through END. This keeps upsert idempotent even when a user accidentally deletes one marker. 2. Parse .mdc YAML frontmatter properly — new _ensure_mdc_frontmatter() helper parses existing frontmatter and ensures alwaysApply: true is set, rather than just checking for the --- delimiter. Handles missing frontmatter, existing frontmatter without alwaysApply, and already-correct frontmatter. --- src/specify_cli/integrations/base.py | 54 +++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 02632d4da9..b0877afb0f 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -387,6 +387,43 @@ def install_scripts( # -- Agent context file management ------------------------------------ + MDC_FRONTMATTER = "---\nalwaysApply: true\n---" + + @staticmethod + def _ensure_mdc_frontmatter(content: str) -> str: + """Ensure ``.mdc`` content has YAML frontmatter with ``alwaysApply: true``. + + If frontmatter is missing, prepend it. If frontmatter exists but + ``alwaysApply`` is absent or not ``true``, inject/fix it. + """ + import yaml as _yaml + + stripped = content.lstrip() + if not stripped.startswith("---"): + return "---\nalwaysApply: true\n---\n\n" + content + + # Parse existing frontmatter + end = stripped.find("\n---", 3) + if end == -1: + return "---\nalwaysApply: true\n---\n\n" + content + + fm_text = stripped[4:end] # between first --- and closing --- + try: + fm = _yaml.safe_load(fm_text) + except Exception: + fm = None + if not isinstance(fm, dict): + fm = {} + + if fm.get("alwaysApply") is True: + return content # already correct + + fm["alwaysApply"] = True + new_fm = _yaml.safe_dump(fm, sort_keys=False).strip() + # Reconstruct: frontmatter + rest of file after closing --- + rest = stripped[end + 4:] # after \n--- + return f"---\n{new_fm}\n---{rest}" + @staticmethod def _build_context_section(plan_path: str = "") -> str: """Build the content for the managed section between markers. @@ -442,8 +479,17 @@ def upsert_context_section( if end_of_marker < len(content) and content[end_of_marker] == "\n": end_of_marker += 1 new_content = content[:start_idx] + section + content[end_of_marker:] + elif start_idx != -1: + # Corrupted: start marker without end — replace from start through EOF + new_content = content[:start_idx] + section + elif end_idx != -1: + # Corrupted: end marker without start — replace BOF through end marker + end_of_marker = end_idx + len(self.CONTEXT_MARKER_END) + if end_of_marker < len(content) and content[end_of_marker] == "\n": + end_of_marker += 1 + new_content = section + content[end_of_marker:] else: - # Markers not found — append + # No markers found — append if content: if not content.endswith("\n"): content += "\n" @@ -452,13 +498,13 @@ def upsert_context_section( new_content = section # Ensure .mdc files have required YAML frontmatter - if ctx_path.suffix == ".mdc" and not new_content.lstrip().startswith("---"): - new_content = "---\nalwaysApply: true\n---\n\n" + new_content + if ctx_path.suffix == ".mdc": + new_content = self._ensure_mdc_frontmatter(new_content) else: ctx_path.parent.mkdir(parents=True, exist_ok=True) # Cursor .mdc files require YAML frontmatter to be loaded if ctx_path.suffix == ".mdc": - new_content = "---\nalwaysApply: true\n---\n\n" + section + new_content = self._ensure_mdc_frontmatter(section) else: new_content = section From 841bcd85b566d0eb6d8360d46a66c50db01d9f5c Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:38:37 -0500 Subject: [PATCH 08/11] fix: preserve .mdc frontmatter, add tests, clean up on switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Rewrite _ensure_mdc_frontmatter() with regex — preserves comments, formatting, and custom keys in existing frontmatter instead of destructively re-serializing via yaml.safe_dump(). Inserts or fixes alwaysApply: true in place. 2. Add 6 focused .mdc frontmatter tests to cursor-agent test file: new file creation, missing frontmatter, preserved custom keys, wrong alwaysApply value, idempotent upserts, removal cleanup. 3. Call remove_context_section() during integration switch Phase 1 — prevents stale SPECKIT markers from being left in the old integration's context file. Also clear context_file from init-options during the metadata reset. --- src/specify_cli/__init__.py | 2 + src/specify_cli/integrations/base.py | 59 +++++++++----- .../test_integration_cursor_agent.py | 80 +++++++++++++++++++ 3 files changed, 120 insertions(+), 21 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 274db57bcc..8c6fd02b9f 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -2155,6 +2155,7 @@ def integration_switch( ) raise typer.Exit(1) removed, skipped = old_manifest.uninstall(project_root, force=force) + current_integration.remove_context_section(project_root) if removed: console.print(f" Removed {len(removed)} file(s)") if skipped: @@ -2185,6 +2186,7 @@ def integration_switch( opts.pop("integration", None) opts.pop("ai", None) opts.pop("ai_skills", None) + opts.pop("context_file", None) save_init_options(project_root, opts) # Ensure shared infrastructure is present (safe to run unconditionally; diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index b0877afb0f..a60f8c0428 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -395,34 +395,51 @@ def _ensure_mdc_frontmatter(content: str) -> str: If frontmatter is missing, prepend it. If frontmatter exists but ``alwaysApply`` is absent or not ``true``, inject/fix it. + + Uses string/regex manipulation to preserve comments and formatting + in existing frontmatter. """ - import yaml as _yaml + import re as _re + + leading_ws = len(content) - len(content.lstrip()) + leading = content[:leading_ws] + stripped = content[leading_ws:] - stripped = content.lstrip() if not stripped.startswith("---"): return "---\nalwaysApply: true\n---\n\n" + content - # Parse existing frontmatter - end = stripped.find("\n---", 3) - if end == -1: + # Match frontmatter block: ---\n...\n--- + match = _re.match( + r"^(---[ \t]*\r?\n)(.*?)(\r?\n---[ \t]*)(\r?\n|$)(.*)", + stripped, + _re.DOTALL, + ) + if not match: return "---\nalwaysApply: true\n---\n\n" + content - fm_text = stripped[4:end] # between first --- and closing --- - try: - fm = _yaml.safe_load(fm_text) - except Exception: - fm = None - if not isinstance(fm, dict): - fm = {} - - if fm.get("alwaysApply") is True: - return content # already correct - - fm["alwaysApply"] = True - new_fm = _yaml.safe_dump(fm, sort_keys=False).strip() - # Reconstruct: frontmatter + rest of file after closing --- - rest = stripped[end + 4:] # after \n--- - return f"---\n{new_fm}\n---{rest}" + opening, fm_text, closing, sep, rest = match.groups() + newline = "\r\n" if "\r\n" in opening else "\n" + + # Already correct? + if _re.search( + r"(?m)^[ \t]*alwaysApply[ \t]*:[ \t]*true[ \t]*(?:#.*)?$", fm_text + ): + return content + + # alwaysApply exists but wrong value — fix in place + if _re.search(r"(?m)^[ \t]*alwaysApply[ \t]*:", fm_text): + fm_text = _re.sub( + r"(?m)^([ \t]*)alwaysApply[ \t]*:.*$", + r"\1alwaysApply: true", + fm_text, + count=1, + ) + elif fm_text.strip(): + fm_text = fm_text + newline + "alwaysApply: true" + else: + fm_text = "alwaysApply: true" + + return f"{leading}{opening}{fm_text}{closing}{sep}{rest}" @staticmethod def _build_context_section(plan_path: str = "") -> str: diff --git a/tests/integrations/test_integration_cursor_agent.py b/tests/integrations/test_integration_cursor_agent.py index 3384fdc14f..352a0475b5 100644 --- a/tests/integrations/test_integration_cursor_agent.py +++ b/tests/integrations/test_integration_cursor_agent.py @@ -1,5 +1,10 @@ """Tests for CursorAgentIntegration.""" +from pathlib import Path + +from specify_cli.integrations import get_integration +from specify_cli.integrations.manifest import IntegrationManifest + from .test_integration_base_skills import SkillsIntegrationTests @@ -11,6 +16,81 @@ class TestCursorAgentIntegration(SkillsIntegrationTests): CONTEXT_FILE = ".cursor/rules/specify-rules.mdc" +class TestCursorMdcFrontmatter: + """Verify .mdc frontmatter handling in upsert/remove context section.""" + + def _setup(self, tmp_path: Path): + i = get_integration("cursor-agent") + m = IntegrationManifest("cursor-agent", tmp_path) + return i, m + + def test_new_mdc_gets_frontmatter(self, tmp_path): + """A freshly created .mdc file includes alwaysApply: true.""" + i, m = self._setup(tmp_path) + i.setup(tmp_path, m) + ctx = (tmp_path / i.context_file).read_text(encoding="utf-8") + assert ctx.startswith("---\n") + assert "alwaysApply: true" in ctx + + def test_existing_mdc_without_frontmatter_gets_it(self, tmp_path): + """An existing .mdc without frontmatter gets it added.""" + i, m = self._setup(tmp_path) + ctx_path = tmp_path / i.context_file + ctx_path.parent.mkdir(parents=True, exist_ok=True) + ctx_path.write_text("# User rules\n", encoding="utf-8") + i.upsert_context_section(tmp_path) + content = ctx_path.read_text(encoding="utf-8") + assert content.lstrip().startswith("---") + assert "alwaysApply: true" in content + assert "# User rules" in content + + def test_existing_mdc_with_frontmatter_preserves_it(self, tmp_path): + """An existing .mdc with custom frontmatter is preserved.""" + i, m = self._setup(tmp_path) + ctx_path = tmp_path / i.context_file + ctx_path.parent.mkdir(parents=True, exist_ok=True) + ctx_path.write_text( + "---\nalwaysApply: true\ncustomKey: hello\n---\n\n# Rules\n", + encoding="utf-8", + ) + i.upsert_context_section(tmp_path) + content = ctx_path.read_text(encoding="utf-8") + assert "alwaysApply: true" in content + assert "customKey: hello" in content + assert "" in content + + def test_existing_mdc_wrong_alwaysapply_fixed(self, tmp_path): + """An .mdc with alwaysApply: false gets corrected.""" + i, m = self._setup(tmp_path) + ctx_path = tmp_path / i.context_file + ctx_path.parent.mkdir(parents=True, exist_ok=True) + ctx_path.write_text( + "---\nalwaysApply: false\n---\n\n# Rules\n", + encoding="utf-8", + ) + i.upsert_context_section(tmp_path) + content = ctx_path.read_text(encoding="utf-8") + assert "alwaysApply: true" in content + assert "alwaysApply: false" not in content + + def test_upsert_idempotent_no_duplicate_frontmatter(self, tmp_path): + """Repeated upserts don't duplicate frontmatter.""" + i, m = self._setup(tmp_path) + i.upsert_context_section(tmp_path) + i.upsert_context_section(tmp_path) + content = (tmp_path / i.context_file).read_text(encoding="utf-8") + assert content.count("alwaysApply") == 1 + + def test_remove_deletes_mdc_with_only_frontmatter(self, tmp_path): + """Removing the section from a Speckit-only .mdc deletes the file.""" + i, m = self._setup(tmp_path) + i.upsert_context_section(tmp_path) + ctx_path = tmp_path / i.context_file + assert ctx_path.exists() + i.remove_context_section(tmp_path) + assert not ctx_path.exists() + + class TestCursorAgentAutoPromote: """--ai cursor-agent auto-promotes to integration path.""" From 03af8ec4eb97de5f953dccc069fa42320b00bf7c Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:51:43 -0500 Subject: [PATCH 09/11] fix: remove unused MDC_FRONTMATTER, preserve inline comments, normalize bare CR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Remove unused MDC_FRONTMATTER class variable — dead code after _ensure_mdc_frontmatter() was rewritten with regex. 2. Preserve inline comments when fixing alwaysApply — the regex substitution now captures trailing '# comment' text and keeps it. 3. Normalize bare CR in upsert_context_section() — match the behavior of remove_context_section() which already normalizes both CRLF and bare CR. 4. Clarify .mdc removal comment — 'treat frontmatter-only as empty' instead of misleading 'strip frontmatter'. --- src/specify_cli/integrations/base.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index a60f8c0428..67c5c8105d 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -387,8 +387,6 @@ def install_scripts( # -- Agent context file management ------------------------------------ - MDC_FRONTMATTER = "---\nalwaysApply: true\n---" - @staticmethod def _ensure_mdc_frontmatter(content: str) -> str: """Ensure ``.mdc`` content has YAML frontmatter with ``alwaysApply: true``. @@ -426,11 +424,12 @@ def _ensure_mdc_frontmatter(content: str) -> str: ): return content - # alwaysApply exists but wrong value — fix in place + # alwaysApply exists but wrong value — fix in place while preserving + # indentation and any trailing inline comment. if _re.search(r"(?m)^[ \t]*alwaysApply[ \t]*:", fm_text): fm_text = _re.sub( - r"(?m)^([ \t]*)alwaysApply[ \t]*:.*$", - r"\1alwaysApply: true", + r"(?m)^([ \t]*)alwaysApply[ \t]*:.*?([ \t]*(?:#.*)?)$", + r"\1alwaysApply: true\2", fm_text, count=1, ) @@ -525,7 +524,7 @@ def upsert_context_section( else: new_content = section - normalized = new_content.replace("\r\n", "\n") + normalized = new_content.replace("\r\n", "\n").replace("\r", "\n") ctx_path.write_bytes(normalized.encode("utf-8")) return ctx_path @@ -567,10 +566,10 @@ def remove_context_section(self, project_root: Path) -> bool: # Normalize line endings before comparisons normalized = new_content.replace("\r\n", "\n").replace("\r", "\n") - # For .mdc files, also strip Speckit-generated frontmatter + # For .mdc files, treat Speckit-generated frontmatter-only content as empty if ctx_path.suffix == ".mdc": import re - # Treat as empty if only YAML frontmatter remains (no body content) + # Delete the file if only YAML frontmatter remains (no body content) frontmatter_only = re.match( r"^---\n.*?\n---\s*$", normalized, re.DOTALL ) From b1dddad7f2c50f69a322ec460f460f74d72804a7 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:00:00 -0500 Subject: [PATCH 10/11] fix: handle corrupted markers in remove, CRLF-safe end-marker consumption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Handle corrupted markers in remove_context_section() — mirror upsert's behavior: start-only removes start→EOF, end-only removes BOF→end. Previously bailed out leaving partial markers behind. 2. CRLF-safe end-marker consumption — both upsert and remove now handle \r\n after the end marker, not just \n. Prevents extra blank lines at replacement boundaries in CRLF files. 3. Clarify path rule in plan template — distinguish filesystem operations (absolute paths) from documentation/agent context references (project-relative paths). --- src/specify_cli/integrations/base.py | 34 +++++++++++++++++++++------- templates/commands/plan.md | 2 +- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 67c5c8105d..9ab3fc8ab8 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -492,6 +492,9 @@ def upsert_context_section( if start_idx != -1 and end_idx != -1 and end_idx > start_idx: # Replace existing section (include the end marker + newline) end_of_marker = end_idx + len(self.CONTEXT_MARKER_END) + # Consume trailing line ending (CRLF or LF) + if end_of_marker < len(content) and content[end_of_marker] == "\r": + end_of_marker += 1 if end_of_marker < len(content) and content[end_of_marker] == "\n": end_of_marker += 1 new_content = content[:start_idx] + section + content[end_of_marker:] @@ -501,6 +504,8 @@ def upsert_context_section( elif end_idx != -1: # Corrupted: end marker without start — replace BOF through end marker end_of_marker = end_idx + len(self.CONTEXT_MARKER_END) + if end_of_marker < len(content) and content[end_of_marker] == "\r": + end_of_marker += 1 if end_of_marker < len(content) and content[end_of_marker] == "\n": end_of_marker += 1 new_content = section + content[end_of_marker:] @@ -549,19 +554,32 @@ def remove_context_section(self, project_root: Path) -> bool: start_idx if start_idx != -1 else 0, ) - if start_idx == -1 or end_idx == -1 or end_idx <= start_idx: + if start_idx != -1 and end_idx != -1 and end_idx > start_idx: + removal_start = start_idx + removal_end = end_idx + len(self.CONTEXT_MARKER_END) + elif start_idx != -1: + # Corrupted: start marker without end — remove from start through EOF + removal_start = start_idx + removal_end = len(content) + elif end_idx != -1: + # Corrupted: end marker without start — remove BOF through end marker + removal_start = 0 + removal_end = end_idx + len(self.CONTEXT_MARKER_END) + else: return False - end_of_marker = end_idx + len(self.CONTEXT_MARKER_END) - if end_of_marker < len(content) and content[end_of_marker] == "\n": - end_of_marker += 1 + # Consume trailing line ending (CRLF or LF) + if removal_end < len(content) and content[removal_end] == "\r": + removal_end += 1 + if removal_end < len(content) and content[removal_end] == "\n": + removal_end += 1 # Also strip a blank line before the section if present - if start_idx > 0 and content[start_idx - 1] == "\n": - if start_idx > 1 and content[start_idx - 2] == "\n": - start_idx -= 1 + if removal_start > 0 and content[removal_start - 1] == "\n": + if removal_start > 1 and content[removal_start - 2] == "\n": + removal_start -= 1 - new_content = content[:start_idx] + content[end_of_marker:] + new_content = content[:removal_start] + content[removal_end:] # Normalize line endings before comparisons normalized = new_content.replace("\r\n", "\n").replace("\r", "\n") diff --git a/templates/commands/plan.md b/templates/commands/plan.md index df30865a0d..04db94ffaa 100644 --- a/templates/commands/plan.md +++ b/templates/commands/plan.md @@ -148,5 +148,5 @@ You **MUST** consider the user input before proceeding (if not empty). ## Key rules -- Use absolute paths +- Use absolute paths for filesystem operations; use project-relative paths for references in documentation and agent context files - ERROR on gate failures or unresolved clarifications From b1f495e9d6599efda3aa492ef941dfb29de59dc7 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:45:03 -0500 Subject: [PATCH 11/11] fix: only remove context section when both markers are well-ordered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit remove_context_section() previously treated mismatched markers as corruption and aggressively removed from BOF→end-marker or start-marker→EOF, which could delete user-authored content if only one marker remained. Now it only removes when both START and END markers exist and are properly ordered, returning False otherwise. --- src/specify_cli/integrations/base.py | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 9ab3fc8ab8..4c71b165e5 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -554,20 +554,15 @@ def remove_context_section(self, project_root: Path) -> bool: start_idx if start_idx != -1 else 0, ) - if start_idx != -1 and end_idx != -1 and end_idx > start_idx: - removal_start = start_idx - removal_end = end_idx + len(self.CONTEXT_MARKER_END) - elif start_idx != -1: - # Corrupted: start marker without end — remove from start through EOF - removal_start = start_idx - removal_end = len(content) - elif end_idx != -1: - # Corrupted: end marker without start — remove BOF through end marker - removal_start = 0 - removal_end = end_idx + len(self.CONTEXT_MARKER_END) - else: + # Only remove a complete, well-ordered managed section. If either + # marker is missing, leave the file unchanged to avoid deleting + # unrelated user-authored content. + if start_idx == -1 or end_idx == -1 or end_idx <= start_idx: return False + removal_start = start_idx + removal_end = end_idx + len(self.CONTEXT_MARKER_END) + # Consume trailing line ending (CRLF or LF) if removal_end < len(content) and content[removal_end] == "\r": removal_end += 1