diff --git a/.github/workflows/cmind-pre-release.yml b/.github/workflows/cmind-pre-release.yml index b1ccf42..11edaa9 100644 --- a/.github/workflows/cmind-pre-release.yml +++ b/.github/workflows/cmind-pre-release.yml @@ -38,6 +38,10 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Install release renderer dependency + if: steps.check_release.outputs.exists == 'false' + run: python -m pip install "pyyaml>=6.0" + - name: Create release package variants if: steps.check_release.outputs.exists == 'false' run: .github/workflows/scripts/cmind/create-release-packages.sh "${{ steps.get_tag.outputs.new_version }}" diff --git a/.github/workflows/cmind-release.yml b/.github/workflows/cmind-release.yml index 246c455..417931e 100644 --- a/.github/workflows/cmind-release.yml +++ b/.github/workflows/cmind-release.yml @@ -41,6 +41,10 @@ jobs: if: steps.check_release.outputs.exists == 'false' run: .github/workflows/scripts/cmind/update-version.sh "${{ steps.get_tag.outputs.new_version }}" + - name: Install release renderer dependency + if: steps.check_release.outputs.exists == 'false' + run: python -m pip install "pyyaml>=6.0" + - name: Create release package variants if: steps.check_release.outputs.exists == 'false' run: .github/workflows/scripts/cmind/create-release-packages.sh "${{ steps.get_tag.outputs.new_version }}" diff --git a/.github/workflows/scripts/cmind/create-release-packages.ps1 b/.github/workflows/scripts/cmind/create-release-packages.ps1 index f2f45b9..cbf5302 100755 --- a/.github/workflows/scripts/cmind/create-release-packages.ps1 +++ b/.github/workflows/scripts/cmind/create-release-packages.ps1 @@ -56,6 +56,7 @@ if (-not (Test-Path $ProjectRoot)) { exit 1 } Set-Location $ProjectRoot +$PythonBin = if ($env:PYTHON) { $env:PYTHON } else { "python" } Write-Host "Building release packages for $Version from $ProjectRoot" @@ -320,8 +321,10 @@ function Build-Variant { Generate-Commands -Extension 'md' -OutputDir $cmdDir } 'codex' { - $cmdDir = Join-Path $baseDir ".codex/prompts" - Generate-Commands -Extension 'md' -OutputDir $cmdDir + & $PythonBin "src/cmind_cli/_codex_skills.py" "templates/commands" $baseDir + if ($LASTEXITCODE -ne 0) { + throw "Codex skill rendering failed with exit code $LASTEXITCODE" + } } 'codebuddy' { $cmdDir = Join-Path $baseDir ".codebuddy/commands" diff --git a/.github/workflows/scripts/cmind/create-release-packages.sh b/.github/workflows/scripts/cmind/create-release-packages.sh index 431f55b..704cce1 100755 --- a/.github/workflows/scripts/cmind/create-release-packages.sh +++ b/.github/workflows/scripts/cmind/create-release-packages.sh @@ -230,8 +230,7 @@ SETTINGS mkdir -p "$base_dir/.augment/commands" generate_commands md "$base_dir/.augment/commands" ;; codex) - mkdir -p "$base_dir/.codex/prompts" - generate_commands md "$base_dir/.codex/prompts" ;; + "$PYTHON_BIN" src/cmind_cli/_codex_skills.py templates/commands "$base_dir" ;; codebuddy) mkdir -p "$base_dir/.codebuddy/commands" generate_commands md "$base_dir/.codebuddy/commands" ;; diff --git a/CoderMind/README.md b/CoderMind/README.md index 8a3b6be..266943d 100644 --- a/CoderMind/README.md +++ b/CoderMind/README.md @@ -93,7 +93,7 @@ Below is part of the graph visualization generated for this repository. After ru - Python 3.12+ - [uv](https://docs.astral.sh/uv/) - Git -- An installed and authenticated AI coding agent CLI: [GitHub Copilot](https://docs.github.com/en/copilot) or [Claude Code](https://docs.anthropic.com/en/docs/claude-code/setup) +- An installed and authenticated AI coding agent CLI: [GitHub Copilot](https://docs.github.com/en/copilot), [Claude Code](https://docs.anthropic.com/en/docs/claude-code/setup), or [Codex](https://developers.openai.com/codex/cli/) ### Install CoderMind @@ -219,7 +219,7 @@ cmind update | -------------- | --------- | ----------------------- | | Claude Code | ✅ | ✅ | | GitHub Copilot | ✅ | ✅ | -| Codex | ⌛ | ⌛ | +| Codex | ✅ | ⌛ | **Operating system support**: diff --git a/CoderMind/docs/cli-reference.md b/CoderMind/docs/cli-reference.md index 8c7d33c..5cc5623 100644 --- a/CoderMind/docs/cli-reference.md +++ b/CoderMind/docs/cli-reference.md @@ -16,7 +16,7 @@ cmind init . [options] | Option | Description | | ------ | ----------- | -| `--ai ` | AI assistant: `copilot` or `claude` | +| `--ai ` | Default encoder/decoder backend: `copilot`, `claude`, or `codex` | | `--script ` | Script type: `sh` (POSIX). `ps` (PowerShell) is not yet supported and will be added in a future release. | | `--here` | Initialize in current directory | | `--force` | Skip confirmation for non-empty current directory | @@ -28,18 +28,21 @@ cmind init . [options] ### Supported AI Assistants -| Agent | Folder | Description | Status | -| ----- | ------ | ----------- | ------ | -| `copilot` | `.github/`, `.vscode/` | GitHub Copilot | Verified | -| `claude` | `.claude/` | Claude Code | Verified | +| Agent | Folder | Invocation | Status | +| ----- | ------ | ---------- | ------ | +| `copilot` | `.github/`, `.vscode/` | `/cmind.*` | Verified | +| `claude` | `.claude/` | `/cmind.*` | Verified | +| `codex` | `.agents/skills/`, `.codex/` | `$cmind-*` or `/skills` | CLI verified | -CoderMind currently supports only **GitHub Copilot** and **Claude Code** in the CLI. Additional agents may be adapted in future releases. +All three integrations are generated together. `--ai` selects only the default +LLM backend used by encoder/decoder pipeline calls. ### Examples ```bash cmind init my-project cmind init my-project --ai claude --script sh +cmind init my-project --ai codex --script sh cmind init . --force cmind init . --encode cmind init . --force --encode @@ -48,7 +51,9 @@ cmind init --here --ai copilot ## `cmind update` -Update CoderMind template files, scripts, command definitions, MCP configuration, gitignore rules, and hooks in an existing project. The AI assistant is auto-detected from existing project configuration when possible. +Update CoderMind template files, all agent integrations, MCP configuration, +gitignore rules, and hooks. The active LLM backend is preserved unless `--ai` +is passed. ```bash cmind update @@ -61,7 +66,7 @@ cmind update --no-upgrade | Option | Description | | ------ | ----------- | -| `--ai ` | AI assistant, auto-detected if not specified | +| `--ai ` | Change the active encoder/decoder backend | | `--script ` | Script type: `sh` (POSIX). `ps` (PowerShell) is not yet supported and will be added in a future release. | | `--no-upgrade` | Skip the default-on CLI self-upgrade and only sync workspace files. | | `--no-mcp` | Skip MCP server configuration | @@ -100,6 +105,18 @@ prints a tree of which ones are available. Run this after installation to confirm the environment is ready, or whenever a pipeline step complains about a missing tool. +## `cmind config` + +Inspect or switch the active encoder/decoder LLM backend without changing the +installed Claude, Copilot, or Codex integrations. + +```bash +cmind config show +cmind config set-agent codex +cmind config set-agent claude +cmind config set-agent copilot +``` + ## `cmind version` Display version and system information. @@ -140,12 +157,10 @@ cmind script --list cmind script --where mcp_server.py ``` -The slash-command templates installed by `cmind init` (in -`.claude/commands/` or `.github/agents/`) all use `cmind script …` -under the hood, so AI agents invoke the pipeline through the same +The Claude/Copilot commands and Codex skills installed by `cmind init` all use +`cmind script …` under the hood, so every agent invokes the same pipeline contract. A companion console script, `cmind-mcp`, is the MCP server entry -point and is what `.mcp.json` / `.vscode/mcp.json` register as the -`rpg-tools` command — no absolute paths in the config, no per-machine -edits. +point and is what `.mcp.json`, `.vscode/mcp.json`, and +`.codex/config.toml` register as the `rpg-tools` command. diff --git a/CoderMind/docs/configuration.md b/CoderMind/docs/configuration.md index 8272f35..73b4735 100644 --- a/CoderMind/docs/configuration.md +++ b/CoderMind/docs/configuration.md @@ -14,6 +14,7 @@ Currently verified assistants: | ----- | ------------ | ----------------------- | ----------- | | GitHub Copilot | `copilot` | `.github/`, `.vscode/` | Copilot CLI available and authenticated | | Claude Code | `claude` | `.claude/` | Claude Code CLI available and authenticated | +| Codex | `codex` | `.agents/skills/`, `.codex/` | Codex CLI available and authenticated | Use `cmind check` to verify required local tools. @@ -21,7 +22,7 @@ Use `cmind check` to verify required local tools. cmind check ``` -If the selected AI assistant is not found, install and authenticate it, then rerun `cmind init` or `cmind update`. +If the selected LLM backend is not found, install and authenticate it, then rerun `cmind init` or `cmind update`. ## Workspace Configuration (`.cmind/config.toml`) @@ -66,7 +67,8 @@ The values written to `ai_cli_cmd` mirror the per-AI substitutions performed by | `opencode` | `opencode run` | | `amp` | `amp --execute` | -Only `copilot` and `claude` are currently verified end-to-end; the others are scaffolded but may need integration adjustments. +`copilot`, `claude`, and `codex` are verified end-to-end. The remaining mappings +are scaffolded but are not exposed as supported backends. ### Other config keys @@ -79,9 +81,14 @@ The `[cmind]` table currently holds only `ai_cli_cmd`. Future releases will add ```bash cmind init my-project --ai claude cmind init my-project --ai copilot +cmind init my-project --ai codex ``` -If `--ai` is omitted in an interactive terminal, CoderMind prompts for a supported assistant. +All three agent integrations are generated together. `--ai` chooses the default +encoder/decoder backend. If omitted, CoderMind prompts in an interactive terminal +and defaults to Copilot in non-interactive environments. + +Switch later with `cmind config set-agent `. ### Script type @@ -109,6 +116,10 @@ cmind update --no-mcp Skipping MCP means the slash-command pipeline still works, but the AI assistant will not get the `rpg-tools` graph-query tools automatically. +CoderMind writes project-scoped MCP entries for Claude (`.mcp.json`), Copilot +(`.vscode/mcp.json`), and Codex (`.codex/config.toml`). Codex user configuration +under `~/.codex/` and `CODEX_HOME` is never modified. + ### Initial encode The MCP tools query `.cmind/data/rpg.json`. For existing codebases, that file is created by the encoder. @@ -173,6 +184,27 @@ For Copilot, CoderMind writes agent instructions under `.github/` and VS Code MC Open the project in VS Code after initialization so the workspace MCP configuration is available to Copilot. +### Codex + +CoderMind renders the shared workflows as repository-scoped skills: + +```text +.agents/skills/ +├── cmind-encode/SKILL.md +├── cmind-plan/SKILL.md +└── cmind-code-gen/SKILL.md +.codex/config.toml # project-scoped rpg-tools MCP registration +``` + +CoderMind skills are explicit-only so they do not compete with ordinary coding +requests. In Codex CLI, type `$` to select `$cmind-encode`, `$cmind-plan`, and +the other CoderMind skills, or use `/skills` to browse them. Explicit-only skills +are intentionally omitted from Codex's model-visible implicit skill list until +the user invokes one. + +Use `$cmind-encode`, `$cmind-plan`, and the other `$cmind-*` skills. Codex also +discovers them through `/skills`. + ## Auto-approval and Scope CoderMind pre-authorizes the `rpg-tools` MCP server where the selected assistant supports project-scoped permissions. The goal is to avoid prompting on every graph query during chat. diff --git a/CoderMind/docs/project-structure.md b/CoderMind/docs/project-structure.md index c99e7e6..ab7943e 100644 --- a/CoderMind/docs/project-structure.md +++ b/CoderMind/docs/project-structure.md @@ -4,7 +4,7 @@ CoderMind installs alongside your project code: the directory you run `cmind init` in, also called the workspace root, **is** the project repository root. There is no separate `repo/` subdirectory. This means: -- `cmind init my-project` creates `my-project/` containing both your source code (`src/`, `tests/`, `docs/`) and CoderMind's in-workspace configuration files (`.cmind/config.toml`, `.claude/`, `.github/`, `.vscode/`, depending on the selected agent). +- `cmind init my-project` creates `my-project/` containing both your source code and all Claude, Copilot, and Codex integrations. The selected `--ai` value controls the encoder/decoder backend only. - `cmind init --here` inside an existing git repository adds CoderMind on top of the existing code without moving the repository. - A single `.git` repository tracks user-owned code and any CoderMind files the user chooses to commit. **Runtime data, logs, and the inner-git snapshot repo all live outside the workspace** under `~/.cmind/workspaces//`, so generated artefacts don't pollute your repo or accidentally get committed. Only a small set of user-facing files (`.cmind/config.toml`, `.cmind/reports/*.html`) stay inside the workspace. @@ -17,7 +17,7 @@ my-project/ ├── docs/ # Optional requirement docs for /cmind.feature_spec │ ├── project_charter.md # Auto-detected when no description is provided │ └── ... -├── .claude/ # Claude Code configuration when --ai claude +├── .claude/ # Claude Code commands and settings │ ├── commands/ # /cmind.* command definitions │ │ ├── cmind.feature_spec.md │ │ ├── cmind.feature_build.md @@ -33,12 +33,17 @@ my-project/ │ │ ├── cmind.encode.md │ │ └── cmind.update_rpg.md │ └── settings.json # Permissions and MCP auto-approval -├── .github/ # Copilot configuration when --ai copilot +├── .github/ # Copilot agents and prompts │ ├── agents/ # cmind.* agent definitions │ └── prompts/ # companion prompts ├── .vscode/ # Copilot/VS Code configuration when applicable │ ├── mcp.json # MCP server registration │ └── tasks.json # Optional workspace tasks +├── .agents/skills/ # Codex project skills +│ ├── cmind-encode/SKILL.md +│ ├── cmind-plan/SKILL.md +│ └── ... +├── .codex/config.toml # Codex project MCP registration └── .cmind/ │ ├── config.toml # Workspace AI / config (committed). See docs/configuration.md │ ├── .source # Provisioning channel marker: "bundle" or "legacy" @@ -68,7 +73,8 @@ Reports (`rpg.html`, review HTML, …) stay **inside** the workspace at ` Pipeline scripts (formerly materialised into `.cmind/scripts/`) now live inside the installed `cmind-cli` wheel under `cmind_cli/core_pack/scripts/` and are invoked via the global [`cmind script `](cli-reference.md) command. They are no longer copied into each workspace, so `cmind init` produces a much smaller footprint and a single source of truth per CLI install. -The agent configuration directory varies by the selected AI assistant and release package. For the verified CLI path, `--ai claude` installs `.claude/commands/`, while `--ai copilot` installs `.github/agents/`, `.github/prompts/`, and `.vscode/mcp.json`. +CoderMind always installs all verified agent integrations. `--ai` only selects +which CLI executes encoder/decoder LLM requests. Command definitions are installed into the AI-agent-specific folder. Normal users should not need to inspect `~/.cmind/workspaces//data/` directly—run `cmind version` from the workspace to see all relevant paths. @@ -79,8 +85,8 @@ Command definitions are installed into the AI-agent-specific folder. Normal user | Your source code | `/` | | Workspace AI config | `/.cmind/config.toml` | | User-facing HTML reports (`rpg.html`, …) | `/.cmind/reports/` | -| Agent command definitions | `/.claude/` or `/.github/` | -| MCP / VS Code config | `/.vscode/` | +| Agent command definitions | `/.claude/`, `.github/`, and `.agents/skills/` | +| MCP config | `/.mcp.json`, `.vscode/mcp.json`, and `.codex/config.toml` | | Git hooks (`post-commit`, `post-merge`) | `/.git/hooks/` | | Generated data (`rpg.json`, `dep_graph.json`, …) | `~/.cmind/workspaces//data/` | | Per-stage logs | `~/.cmind/workspaces//logs/` | diff --git a/CoderMind/pyproject.toml b/CoderMind/pyproject.toml index f73a08c..888b3ff 100644 --- a/CoderMind/pyproject.toml +++ b/CoderMind/pyproject.toml @@ -33,8 +33,9 @@ dependencies = [ "tqdm", "openai>=1.0.0", "anthropic>=0.20.0", - "mcp>=1.0.0", - "pyyaml>=6.0" + "mcp>=1.0.0,<2", + "pyyaml>=6.0", + "tomlkit>=0.13.0" ] [project.scripts] diff --git a/CoderMind/scripts/code_gen/git_ops.py b/CoderMind/scripts/code_gen/git_ops.py index c05aef3..ebccd90 100644 --- a/CoderMind/scripts/code_gen/git_ops.py +++ b/CoderMind/scripts/code_gen/git_ops.py @@ -59,6 +59,7 @@ def setup_batch_branch( batch_id: str, repo_path: Path, reuse_existing: bool = False, + preserve_existing: bool = False, ) -> Tuple[bool, str, str]: """Create (or reuse) a batch branch from latest main HEAD. @@ -68,6 +69,8 @@ def setup_batch_branch( repo_path: Repo root path. reuse_existing: If True and branch exists, switch to it instead of deleting and recreating. + preserve_existing: If True and branch exists, keep it and create a + uniquely suffixed recovery branch from latest main. Returns: (success, branch_name, initial_commit) @@ -84,6 +87,17 @@ def setup_batch_branch( return False, branch_name, "" initial_commit = git.get_head_commit() return True, branch_name, initial_commit + elif preserve_existing: + recovery_index = 1 + recovery_name = f"{branch_name}-retry-{recovery_index}" + while git.branch_exists(recovery_name): + recovery_index += 1 + recovery_name = f"{branch_name}-retry-{recovery_index}" + branch_name = recovery_name + logger.info( + "Preserving failed branch and creating recovery branch '%s'", + branch_name, + ) else: logger.info("Deleting stale branch '%s' (will recreate from main)", branch_name) git.delete_branch(branch_name, force=True) diff --git a/CoderMind/scripts/common/git_utils.py b/CoderMind/scripts/common/git_utils.py index 3c72866..d2ad2a4 100644 --- a/CoderMind/scripts/common/git_utils.py +++ b/CoderMind/scripts/common/git_utils.py @@ -9,6 +9,7 @@ - Task branch lifecycle (create / merge / abandon) """ +import hashlib import logging import re import subprocess @@ -53,7 +54,13 @@ def sanitize_branch_component( if not safe: return fallback - safe = safe[:max_len].rstrip("._-") + if len(safe) > max_len: + digest = hashlib.sha256(safe.encode("utf-8")).hexdigest()[:8] + if max_len > len(digest) + 1: + prefix = safe[: max_len - len(digest) - 1].rstrip("._-") + safe = f"{prefix}-{digest}" if prefix else digest[:max_len] + else: + safe = digest[:max_len] if safe.endswith(".lock"): safe = safe[: -len(".lock")].rstrip("._-") diff --git a/CoderMind/scripts/common/session_manager.py b/CoderMind/scripts/common/session_manager.py index df8fd5a..d9af7a6 100644 --- a/CoderMind/scripts/common/session_manager.py +++ b/CoderMind/scripts/common/session_manager.py @@ -452,6 +452,52 @@ def after(self, purpose: str) -> Optional[Path]: return None +# ============================================================================ +# Codex CLI manager +# ============================================================================ + +class CodexSessionManager(SessionManager): + """Prepare non-interactive Codex calls with an explicit sandbox opt-out.""" + + _BYPASS_SANDBOX_ENV = "CMIND_CODEX_BYPASS_SANDBOX" + + def __init__( + self, + project_dir: Path, + trace_filename_builder: Optional[Callable[[str], str]] = None, + logger: Optional[logging.Logger] = None, + ) -> None: + super().__init__(project_dir, trace_filename_builder, logger) + self._prompt_file: Optional[Any] = None + + def before(self, ctx: TraceContext, prompt: str) -> None: + self._close_prompt_file() + self._prompt_file = tempfile.TemporaryFile(mode="w+", encoding="utf-8") + self._prompt_file.write(prompt) + self._prompt_file.seek(0) + ctx.stdin = self._prompt_file + permission_args = ( + ["--dangerously-bypass-approvals-and-sandbox"] + if os.environ.get(self._BYPASS_SANDBOX_ENV, "").lower() in {"1", "true", "yes"} + else ["--approve-for-me"] + ) + ctx.extra_args.extend([ + *permission_args, + "--ephemeral", + "--skip-git-repo-check", + "-", + ]) + + def after(self, purpose: str) -> Optional[Path]: + self._close_prompt_file() + return None + + def _close_prompt_file(self) -> None: + if self._prompt_file is not None: + self._prompt_file.close() + self._prompt_file = None + + # ============================================================================ # Factory # ============================================================================ @@ -460,6 +506,7 @@ def after(self, purpose: str) -> Optional[Path]: _MANAGER_REGISTRY: Dict[str, type] = { "claude": ClaudeSessionManager, "copilot": CopilotSessionManager, + "codex": CodexSessionManager, } diff --git a/CoderMind/scripts/design_interfaces.py b/CoderMind/scripts/design_interfaces.py index 5e94227..8f73dd9 100644 --- a/CoderMind/scripts/design_interfaces.py +++ b/CoderMind/scripts/design_interfaces.py @@ -958,12 +958,14 @@ def __init__( max_file_iterations: int = 10, max_planning_retries: int = 3, trajectory: Optional[Trajectory] = None, - output_path: Optional[str] = None + output_path: Optional[str] = None, + restore_existing: bool = True, ): self.max_file_iterations = max_file_iterations self.max_planning_retries = max_planning_retries self.trajectory = trajectory self.output_path = output_path + self.restore_existing = restore_existing self.logger = logging.getLogger(__name__) self._current_step_id: Optional[int] = None self.llm: Optional[LLMClient] = None # Created lazily when step_id is known @@ -1069,6 +1071,7 @@ def build( step_id=self._current_step_id, output_path=self.output_path, target_language=primary_language, + restore_existing=self.restore_existing, ) result = orchestrator.design_all_interfaces( @@ -1613,6 +1616,11 @@ def main(): action="store_true", help="Disable trajectory recording" ) + parser.add_argument( + "--force", + action="store_true", + help="Ignore existing interfaces output and rebuild every subtree", + ) args = parser.parse_args() @@ -1686,7 +1694,8 @@ def main(): designer = InterfaceDesigner( max_file_iterations=args.max_file_iterations, trajectory=trajectory, - output_path=str(args.output) + output_path=str(args.output), + restore_existing=not args.force, ) heartbeat_stop, heartbeat_thread = _start_heartbeat("design_interfaces") diff --git a/CoderMind/scripts/func_design/base_class_agent.py b/CoderMind/scripts/func_design/base_class_agent.py index 23f9764..64407f3 100644 --- a/CoderMind/scripts/func_design/base_class_agent.py +++ b/CoderMind/scripts/func_design/base_class_agent.py @@ -499,7 +499,13 @@ def design_base_classes( f"{len(uncovered)} uncovered data flow types" ) if uncovered: - self.logger.warning(f"[BaseClassAgent] Uncovered data flow types: {sorted(uncovered)}") + last_error = ( + "Every data flow type must be covered by a base class or " + "data_structure.data_flow_types entry. Missing exact values: " + + json.dumps(sorted(uncovered), ensure_ascii=False) + ) + self.logger.warning(f"[BaseClassAgent] {last_error}") + continue return { "base_classes": base_classes, diff --git a/CoderMind/scripts/func_design/interface_agent.py b/CoderMind/scripts/func_design/interface_agent.py index 1fe55ba..2c0d267 100644 --- a/CoderMind/scripts/func_design/interface_agent.py +++ b/CoderMind/scripts/func_design/interface_agent.py @@ -2129,6 +2129,7 @@ def __init__( step_id: Optional[int] = None, output_path: Optional[str] = None, target_language: Optional[str] = None, + restore_existing: bool = True, ): # Create LLMClient with trajectory support if not provided if llm_client is None: @@ -2145,6 +2146,7 @@ def __init__( self.step_id = step_id self.output_path = output_path self.backend = get_backend(target_language) + self.restore_existing = restore_existing def design_all_interfaces( self, @@ -2204,14 +2206,16 @@ def design_all_interfaces( all_import_warnings = [] # collect import cross-validation warnings all_new_features = [] # collect new features created across all subtrees coverage_status = self._new_coverage_status() - restored_subtrees = self._restore_completed_subtrees( - skeleton=skeleton, - subtree_order=subtree_order, - all_interfaces=all_interfaces, - implemented_subtrees=implemented_subtrees, - coverage_status=coverage_status, - global_registry=global_registry, - ) + restored_subtrees = set() + if self.restore_existing: + restored_subtrees = self._restore_completed_subtrees( + skeleton=skeleton, + subtree_order=subtree_order, + all_interfaces=all_interfaces, + implemented_subtrees=implemented_subtrees, + coverage_status=coverage_status, + global_registry=global_registry, + ) if restored_subtrees: restored_in_order = [name for name in subtree_order if name in restored_subtrees] print( diff --git a/CoderMind/scripts/func_design/interface_review.py b/CoderMind/scripts/func_design/interface_review.py index b667170..ad255af 100644 --- a/CoderMind/scripts/func_design/interface_review.py +++ b/CoderMind/scripts/func_design/interface_review.py @@ -283,6 +283,27 @@ def build_call_graph( bare_name = name_parts[1] if len(name_parts) == 2 else unit_name name_to_keys[bare_name].append(unit_key) name_to_keys[unit_name].append(unit_key) + + def resolve_unit_key(name: str, file_path: str = "") -> Optional[str]: + exact_key = f"{file_path}::{name}" if file_path else "" + if exact_key in unit_to_file: + return exact_key + + aliases = [name] + qualified_tail = name.rsplit(".", 1)[-1] + if qualified_tail != name: + aliases.append(qualified_tail) + + candidates = [] + for alias in aliases: + for key in name_to_keys.get(alias, []): + if key not in candidates: + candidates.append(key) + if file_path: + for key in candidates: + if unit_to_file.get(key) == file_path: + return key + return candidates[0] if candidates else None # Process invocation edges from enhanced_data_flow for edge in enhanced_data_flow.get("invocation_edges", []): @@ -291,32 +312,8 @@ def build_call_graph( caller_file = edge.get("caller_file", "") callee_file = edge.get("callee_file", "") - # Resolve caller key - caller_key = f"{caller_file}::{caller}" if caller_file else None - if caller_key and caller_key not in unit_to_file: - # Try to find by name - candidates = name_to_keys.get(caller, []) - if candidates: - caller_key = candidates[0] - else: - caller_key = None - - # Resolve callee key - callee_key = None - if callee_file: - callee_key = f"{callee_file}::{callee}" - if callee_key not in unit_to_file: - # Try matching just by callee name - for key in name_to_keys.get(callee, []): - if unit_to_file.get(key) == callee_file: - callee_key = key - break - else: - candidates = name_to_keys.get(callee, []) - callee_key = candidates[0] if candidates else None - else: - candidates = name_to_keys.get(callee, []) - callee_key = candidates[0] if candidates else None + caller_key = resolve_unit_key(caller, caller_file) + callee_key = resolve_unit_key(callee, callee_file) if caller_key and callee_key: outgoing[caller_key].add(callee_key) diff --git a/CoderMind/scripts/mcp_server.py b/CoderMind/scripts/mcp_server.py index c9d5e17..6095edc 100644 --- a/CoderMind/scripts/mcp_server.py +++ b/CoderMind/scripts/mcp_server.py @@ -26,9 +26,9 @@ import logging import os import sys +import threading import time from datetime import datetime, timezone -from typing import List, Optional # Ensure sibling packages (common/, rpg/) are importable when this script is # invoked by an absolute path (which is how Claude / VS Code launch it). @@ -64,10 +64,13 @@ def _log_tool_call(tool_name: str, params: dict, result_summary: dict, duration_ **result_summary, "duration_ms": duration_ms, } + client_context = os.environ.get("CMIND_MCP_CLIENT_CONTEXT", "").strip() + if client_context: + record["client_context"] = client_context with open(MCP_CALLS_LOG, "a", encoding="utf-8") as f: f.write(json.dumps(record, ensure_ascii=False) + "\n") - except Exception: - pass + except (OSError, TypeError, ValueError) as exc: + logger.debug("Could not write MCP telemetry: %s", exc) # --------------------------------------------------------------------------- @@ -155,29 +158,51 @@ def create_mcp_server(rpg_file: str): """ from mcp.server.fastmcp import FastMCP - # Single-element list used as a mutable box so the per-tool closures - # below can update the cached engine without needing ``nonlocal`` in - # each function. - engine_box: List[Optional[GraphQueryEngine]] = [None] + engine_box: list[GraphQueryEngine | None] = [None] + signature_box: list[tuple[int, int, int, int] | None] = [None] + engine_lock = threading.Lock() - def _get_engine() -> Optional[GraphQueryEngine]: - """Return the cached engine, lazily loading rpg.json on first use. + def _file_signature() -> tuple[int, int, int, int] | None: + try: + stat = os.stat(rpg_file) + except OSError: + return None + return (stat.st_dev, stat.st_ino, stat.st_size, stat.st_mtime_ns) + + def _get_engine() -> GraphQueryEngine | None: + """Load the engine lazily and refresh it when rpg.json changes. Returns ``None`` if the file doesn't exist or fails to load. Errors are logged to stderr — never raised — because raising from a tool handler closes the MCP transport. """ - if engine_box[0] is not None: - return engine_box[0] - if not os.path.isfile(rpg_file): + signature = _file_signature() + if signature is None: + engine_box[0] = None + signature_box[0] = None return None - try: - engine_box[0] = GraphQueryEngine.from_rpg_file(rpg_file) - logger.info("Loaded RPG from %s", rpg_file) + if engine_box[0] is not None and signature_box[0] == signature: return engine_box[0] - except Exception as exc: # noqa: BLE001 - logger.error("Failed to load RPG from %s: %s", rpg_file, exc) - return None + + with engine_lock: + signature = _file_signature() + if signature is None: + engine_box[0] = None + signature_box[0] = None + return None + if engine_box[0] is not None and signature_box[0] == signature: + return engine_box[0] + try: + engine = GraphQueryEngine.from_rpg_file(rpg_file) + except Exception as exc: # noqa: BLE001 + engine_box[0] = None + signature_box[0] = None + logger.error("Failed to load RPG from %s: %s", rpg_file, exc) + return None + engine_box[0] = engine + signature_box[0] = signature + logger.info("Loaded RPG from %s", rpg_file) + return engine def _unavailable_reason() -> str: return ( @@ -291,7 +316,7 @@ def explore_rpg( node_id: str, direction: str = "both", depth: int = 2, - edge_types: Optional[List[str]] = None, + edge_types: list[str] | None = None, ) -> str: """Explore dependencies and call chains from a code entity. @@ -387,6 +412,30 @@ def list_rpg_tree( int((time.monotonic() - t0) * 1000)) return json.dumps(result, indent=2, ensure_ascii=False) + @mcp.resource( + "rpg://tree", + name="rpg_tree", + description=( + "The repository's functional architecture tree at depth 2. " + "Use this resource when the client exposes MCP resources but not " + "custom MCP tools." + ), + mime_type="application/json", + ) + def rpg_tree_resource() -> str: + engine = _get_engine() + if engine is None: + return _unavailable_payload(rpg_file, _unavailable_reason()) + t0 = time.monotonic() + result = engine.list_tree(root_id=None, max_depth=2) + _log_tool_call( + "list_rpg_tree", + {"root_id": "", "max_depth": 2, "transport": "resource"}, + {"total_nodes": result.get("total_nodes", 0)}, + int((time.monotonic() - t0) * 1000), + ) + return json.dumps(result, indent=2, ensure_ascii=False) + return mcp diff --git a/CoderMind/scripts/plan.py b/CoderMind/scripts/plan.py index 90c2cd5..51cade7 100644 --- a/CoderMind/scripts/plan.py +++ b/CoderMind/scripts/plan.py @@ -354,6 +354,8 @@ def _build_args_for(stage: Stage, args: argparse.Namespace) -> list[str]: extra.append("--verbose") if args.no_trajectory: extra.append("--no-trajectory") + if args.force and stage.name == "interfaces": + extra.append("--force") return extra diff --git a/CoderMind/scripts/rpg/dep_graph.py b/CoderMind/scripts/rpg/dep_graph.py index 093228f..6ab1296 100644 --- a/CoderMind/scripts/rpg/dep_graph.py +++ b/CoderMind/scripts/rpg/dep_graph.py @@ -1689,7 +1689,7 @@ def get_name( name += f" {badge_map[ntype]}" if ntype == NodeType.FILE and not for_print: - name = name.rstrip(".py") + name = name.removesuffix(".py") return name diff --git a/CoderMind/scripts/rpg/service.py b/CoderMind/scripts/rpg/service.py index ae8d22b..aa07df2 100644 --- a/CoderMind/scripts/rpg/service.py +++ b/CoderMind/scripts/rpg/service.py @@ -67,7 +67,9 @@ def load(cls, path: str | Path) -> "RPGService": """ _logger = logging.getLogger(__name__) - rpg = RPG.load_json(str(path)) + from common.rpg_io import safe_load_rpg + + rpg = RPG.from_dict(safe_load_rpg(path)) svc = cls(rpg) svc._rpg_dir = Path(path).parent diff --git a/CoderMind/scripts/rpg_edit/code.py b/CoderMind/scripts/rpg_edit/code.py index c070be7..7efe1cd 100644 --- a/CoderMind/scripts/rpg_edit/code.py +++ b/CoderMind/scripts/rpg_edit/code.py @@ -43,6 +43,7 @@ RPG_FILE, REPO_DIR, RPG_EDIT_PLAN_FILE, + RPG_EDIT_CODE_RESULT_FILE, DATA_DIR, WORKSPACE_ROOT, cmd_for, @@ -637,6 +638,17 @@ def apply_code_changes( } +def persist_code_result(result: Dict[str, Any], path: Path) -> None: + """Atomically persist the code-stage result for review and resume.""" + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(f".{path.name}.tmp") + temporary.write_text( + json.dumps(result, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + temporary.replace(path) + + # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- @@ -685,6 +697,15 @@ def main() -> int: timeout=args.timeout, ) + try: + persist_code_result(result, RPG_EDIT_CODE_RESULT_FILE) + except OSError as exc: + result = { + "type": "error", + "success": False, + "error": f"failed to persist code result: {exc}", + } + if args.json: print(json.dumps(result, indent=2, ensure_ascii=False)) else: diff --git a/CoderMind/scripts/rpg_edit/review.py b/CoderMind/scripts/rpg_edit/review.py index 85b8386..ec7be0d 100644 --- a/CoderMind/scripts/rpg_edit/review.py +++ b/CoderMind/scripts/rpg_edit/review.py @@ -34,7 +34,13 @@ if str(SCRIPTS_DIR) not in sys.path: sys.path.insert(0, str(SCRIPTS_DIR)) -from common.paths import REPO_DIR, cmd_for, RPG_EDIT_PLAN_FILE, RPG_EDIT_IMPACT_FILE # noqa: E402 +from common.paths import ( # noqa: E402 + REPO_DIR, + RPG_EDIT_IMPACT_FILE, + RPG_EDIT_PLAN_FILE, + RPG_EDIT_REVIEW_RESULT_FILE, + cmd_for, +) logger = logging.getLogger(__name__) @@ -549,6 +555,17 @@ def impact_review( return results +def persist_review_result(result: Dict[str, Any], path: Path) -> None: + """Atomically persist the review result for workflow resume.""" + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(f".{path.name}.tmp") + temporary.write_text( + json.dumps(result, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + temporary.replace(path) + + # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- @@ -582,7 +599,12 @@ def main(): setup_file_logging("rpg_edit") if not args.plan.exists(): - result = {"type": "error", "message": f"Plan not found: {args.plan}"} + result = { + "type": "error", + "success": False, + "message": f"Plan not found: {args.plan}", + } + persist_review_result(result, RPG_EDIT_REVIEW_RESULT_FILE) print(json.dumps(result) if args.json else f"Error: {result['message']}") return 1 @@ -599,10 +621,12 @@ def main(): if total_callers == 0 and affected_files <= 1: result = { "type": "skipped", + "success": True, "reason": f"Impact too small for sub-agent review " f"(callers={total_callers}, files={affected_files}). " f"Agent self-review is sufficient.", } + persist_review_result(result, RPG_EDIT_REVIEW_RESULT_FILE) print(json.dumps(result, indent=2) if args.json else f"Skipped: {result['reason']}") return 0 @@ -614,6 +638,7 @@ def main(): max_iterations=args.max_iterations, timeout=args.timeout, ) + persist_review_result(result, RPG_EDIT_REVIEW_RESULT_FILE) print(json.dumps(result, indent=2) if args.json else f"Review {'PASSED' if result['success'] else 'FAILED'} " diff --git a/CoderMind/scripts/run_batch.py b/CoderMind/scripts/run_batch.py index d2ed385..ab39146 100644 --- a/CoderMind/scripts/run_batch.py +++ b/CoderMind/scripts/run_batch.py @@ -619,10 +619,14 @@ def run_batch( # ── Step 3: Setup git branch ───────────────────────────────────── - reuse_branch = bool(retry) or resume + reuse_branch = resume try: branch_ok, branch_name, initial_commit = setup_batch_branch( - git, batch_id, repo_path, reuse_existing=reuse_branch, + git, + batch_id, + repo_path, + reuse_existing=reuse_branch, + preserve_existing=bool(retry), ) except RuntimeError as exc: return _error(f"Git setup failed: {exc}", scripts) diff --git a/CoderMind/src/cmind_cli/__init__.py b/CoderMind/src/cmind_cli/__init__.py index 75cbcdc..32ad6e1 100644 --- a/CoderMind/src/cmind_cli/__init__.py +++ b/CoderMind/src/cmind_cli/__init__.py @@ -31,12 +31,9 @@ from rich.console import Console from rich.panel import Panel from rich.progress import ( - BarColumn, - MofNCompleteColumn, Progress, SpinnerColumn, TextColumn, - TimeElapsedColumn, ) from rich.text import Text from rich.live import Live @@ -54,7 +51,7 @@ import importlib.metadata import tomllib -from . import _storage +from . import _codex_config, _codex_skills, _storage, _workspace_config ssl_context = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT) client = httpx.Client(verify=ssl_context) @@ -262,6 +259,12 @@ def _format_rate_limit_error(status_code: int, headers: httpx.Headers, url: str) "install_url": "https://docs.anthropic.com/en/docs/claude-code/setup", "requires_cli": True, }, + "codex": { + "name": "Codex CLI", + "folder": ".agents/", + "install_url": "https://developers.openai.com/codex/cli/", + "requires_cli": True, + }, # --- Unverified agents (commented out until tested) --- # "gemini": { # "name": "Gemini CLI", @@ -287,12 +290,6 @@ def _format_rate_limit_error(status_code: int, headers: httpx.Headers, url: str) # "install_url": "https://opencode.ai", # "requires_cli": True, # }, - # "codex": { - # "name": "Codex CLI", - # "folder": ".codex/", - # "install_url": "https://github.com/openai/codex", - # "requires_cli": True, - # }, # "codebuddy": { # "name": "CodeBuddy", # "folder": ".codebuddy/", @@ -342,22 +339,7 @@ def _format_rate_limit_error(status_code: int, headers: httpx.Headers, url: str) # user's original choice. Mirrors the # constants in :mod:`cmind_cli._storage`. -_AI_TO_CLI_CMD = { - # NOTE: values below are copied verbatim from - # .github/workflows/scripts/cmind/create-release-packages.sh lines ~142-169 - # to guarantee bundle mode and legacy-download mode behave identically. - "copilot": "copilot", - "claude": "claude", - "gemini": "gemini -p", - "qwen": "qwen -p", - "cursor-agent": "agent -p", - "auggie": "augment -p", - "codex": "codex exec", - "codebuddy": "codebuddy -p", - "qoder": "qodercli -p", - "opencode": "opencode run", - "amp": "amp --execute", -} +_AI_TO_CLI_CMD = _workspace_config.AGENT_CLI_COMMANDS # Re-exported (under the older names) to minimise churn at call sites; # the canonical strings now live in :mod:`cmind_cli._storage`. @@ -419,25 +401,7 @@ def _write_workspace_config(project_path: Path, selected_ai: str) -> None: ``ai_cli_cmd``, leave it alone (the user may have customised it). Only writes a fresh file when one is missing. """ - cfg_path = project_path / _CONFIG_RELPATH - cli_cmd = _AI_TO_CLI_CMD.get(selected_ai, selected_ai) - - if cfg_path.exists(): - # Don't clobber user edits. We could merge here, but plain - # workspaces don't need the complexity and a stale value is a - # supported configuration (env var override remains available). - return - - cfg_path.parent.mkdir(parents=True, exist_ok=True) - cfg_path.write_text( - "# CoderMind workspace configuration\n" - "# Managed by `cmind init` / `cmind update`. Safe to commit.\n" - "# See: https://github.com/microsoft/RPG-ZeroRepo (CoderMind/docs/configuration.md)\n" - "\n" - "[cmind]\n" - f'ai_cli_cmd = "{cli_cmd}"\n', - encoding="utf-8", - ) + _workspace_config.initialize(project_path, selected_ai) def _detect_install_method() -> str: @@ -600,7 +564,7 @@ def _install_source() -> str: # its own .gitignore preferences. # * CMIND_COMMON → always injected; these files must be ignored # (runtime data, machine-specific config). -# * CMIND_AI[ai] → always injected for the selected AI assistant. +# * CMIND_AI[ai] → always injected for the selected LLM backend. # # The Python template is a verbatim copy of GitHub's official # ``github/gitignore/Python.gitignore`` (220-line community baseline). @@ -876,9 +840,15 @@ def _install_source() -> str: "claude": """\ # Claude Code slash command definitions (regenerated by cmind) .claude/commands/ +""", + "codex": """\ +# Codex skill definitions (regenerated by cmind) +.agents/skills/cmind-*/ """, } +_INTEGRATED_AGENTS = ("claude", "copilot", "codex") + BANNER = """ ██████╗ ██████╗ ██████╗ ███████╗██████╗ ███╗ ███╗██╗███╗ ██╗██████╗ ██╔════╝██╔═══██╗██╔══██╗██╔════╝██╔══██╗████╗ ████║██║████╗ ██║██╔══██╗ @@ -1117,6 +1087,56 @@ def format_help(self, ctx, formatter): invoke_without_command=True, cls=BannerGroup, ) +config_app = typer.Typer( + help="Inspect or change the active encoder/decoder LLM backend.", + no_args_is_help=True, +) +app.add_typer(config_app, name="config") + + +def _config_workspace() -> Path: + workspace = _storage.find_workspace_root_from(Path.cwd()) + if workspace is None: + console.print( + "[red]Error:[/red] No CoderMind workspace found. " + "Run this command inside a workspace created by `cmind init`." + ) + raise typer.Exit(1) + return workspace + + +@config_app.command("show") +def config_show() -> None: + """Show the active encoder/decoder LLM backend.""" + try: + backend = _workspace_config.read_active_backend(_config_workspace()) + except _workspace_config.WorkspaceConfigError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) from exc + + agent = backend.agent or "custom" + console.print(f"[cyan]Active backend:[/cyan] {agent}") + console.print(f"[cyan]CLI command:[/cyan] {backend.command}") + + +@config_app.command("set-agent") +def config_set_agent( + agent: str = typer.Argument( + ..., + help="Backend for encoder/decoder: copilot, claude, or codex.", + ), +) -> None: + """Set the active encoder/decoder LLM backend.""" + try: + backend = _workspace_config.set_active_backend(_config_workspace(), agent) + except _workspace_config.WorkspaceConfigError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) from exc + + console.print( + f"[green]Active backend set to {backend.agent}[/green] " + f"([cyan]{backend.command}[/cyan])" + ) def show_banner(): @@ -1250,16 +1270,17 @@ def _setup_gitignore(project_path: Path, selected_ai: str) -> None: Args: project_path: Project root that may or may not be a git repo. - selected_ai: ``"copilot"`` or ``"claude"`` — selects which AI - slash-command directories to ignore. + selected_ai: Active LLM backend. Generated integration ignores are + backend-independent; retained for API compatibility. """ gitignore = project_path / ".gitignore" git_dir = project_path / ".git" cmind_block = _GITIGNORE_CMIND_COMMON - ai_rules = _GITIGNORE_CMIND_AI.get(selected_ai) - if ai_rules: - cmind_block += "\n" + ai_rules + for agent in _INTEGRATED_AGENTS: + ai_rules = _GITIGNORE_CMIND_AI.get(agent) + if ai_rules: + cmind_block += "\n" + ai_rules # Greenfield: brand-new project, no git, no existing .gitignore. # Lay down the full template (Python conventions + CoderMind rules). @@ -1511,7 +1532,7 @@ def _generate_mcp_config( selected_ai: str, tracker=None, ) -> None: - """Generate MCP server configuration for the selected AI assistant. + """Generate project-scoped MCP configuration for all integrated agents. Both Claude and VS Code Copilot launch the MCP server via the ``cmind-mcp`` console script installed alongside ``cmind-cli``. @@ -1519,9 +1540,9 @@ def _generate_mcp_config( to a workspace-local copy) and ensures the server always runs against the bundled scripts that match the installed CLI version. - - Claude: ``.mcp.json`` (key ``mcpServers.rpg-tools``) - - Copilot: ``.vscode/mcp.json`` (key ``servers.rpg-tools``, - VS Code 1.102+ standard layout) + - Claude: ``.mcp.json`` (key ``mcpServers.rpg-tools``) + - Copilot: ``.vscode/mcp.json`` (key ``servers.rpg-tools``) + - Codex: ``.codex/config.toml`` (key ``mcp_servers.rpg-tools``) The ``cmind-mcp`` command must be on ``PATH``. ``cmind init`` emits a warning at the end of the run when it isn't, so MCP @@ -1535,54 +1556,58 @@ def _generate_mcp_config( "args": [], } + configured = [] + failures = [] + try: - if selected_ai == "claude": - # Claude Code uses .mcp.json at project root - mcp_file = project_path / ".mcp.json" - mcp_data = _load_json_dict(mcp_file) - mcp_data.setdefault("mcpServers", {}) - mcp_data["mcpServers"]["rpg-tools"] = mcp_server_config - with open(mcp_file, "w", encoding="utf-8") as f: - json.dump(mcp_data, f, indent=2) - f.write("\n") + _codex_config.configure_rpg_tools(project_path) + configured.append("codex") + except Exception as exc: + failures.append(("codex", exc)) - elif selected_ai == "copilot": - # VS Code Copilot (1.102+): .vscode/mcp.json with top-level "servers". - # No ``sandbox`` block: VS Code's MCP sandbox requires bwrap + - # socat which are absent on most Linux desktops, WSL, minimal - # Docker images, and fresh macOS installs, causing the server - # to crash with "Connection closed". Tool auto-approval is - # handled by VS Code's "Always allow this server" setting. - vscode_dir = project_path / ".vscode" - vscode_dir.mkdir(parents=True, exist_ok=True) - mcp_file = vscode_dir / "mcp.json" - mcp_data = _load_json_dict(mcp_file) - mcp_data.setdefault("servers", {}) - mcp_data["servers"]["rpg-tools"] = mcp_server_config - with open(mcp_file, "w", encoding="utf-8") as f: - json.dump(mcp_data, f, indent=2) - f.write("\n") - # Migration: drop a stale rpg-tools entry from .vscode/settings.json - # (older versions registered MCP there). - _cleanup_legacy_vscode_mcp(project_path) + try: + claude_file = project_path / ".mcp.json" + claude_data = _load_json_dict(claude_file) + claude_data.setdefault("mcpServers", {}) + claude_data["mcpServers"]["rpg-tools"] = mcp_server_config + with open(claude_file, "w", encoding="utf-8") as file: + json.dump(claude_data, file, indent=2) + file.write("\n") + configured.append("claude") + except Exception as exc: + failures.append(("claude", exc)) - else: - # For other/future agents, fall back to .mcp.json (Claude format) - mcp_file = project_path / ".mcp.json" - mcp_data = _load_json_dict(mcp_file) - mcp_data.setdefault("mcpServers", {}) - mcp_data["mcpServers"]["rpg-tools"] = mcp_server_config - with open(mcp_file, "w", encoding="utf-8") as f: - json.dump(mcp_data, f, indent=2) - f.write("\n") + try: + vscode_dir = project_path / ".vscode" + vscode_dir.mkdir(parents=True, exist_ok=True) + copilot_file = vscode_dir / "mcp.json" + copilot_data = _load_json_dict(copilot_file) + copilot_data.setdefault("servers", {}) + copilot_data["servers"]["rpg-tools"] = mcp_server_config + with open(copilot_file, "w", encoding="utf-8") as file: + json.dump(copilot_data, file, indent=2) + file.write("\n") + _cleanup_legacy_vscode_mcp(project_path) + configured.append("copilot") + except Exception as exc: + failures.append(("copilot", exc)) + configured_detail = ", ".join(configured) or "none" + if failures: + failure_detail = "; ".join( + f"{agent}: {error}" for agent, error in failures + ) + detail = f"configured for {configured_detail}; failed: {failure_detail}" if tracker: - tracker.complete("mcp", f"configured for {selected_ai}") - except Exception as e: - if tracker: - tracker.error("mcp", f"failed: {e}") + failed_agents = {agent for agent, _ in failures} + if selected_ai in failed_agents: + tracker.error("mcp", detail) + else: + tracker.complete("mcp", detail) else: - console.print(f"[yellow]Warning: Could not generate MCP config: {e}[/yellow]") + console.print(f"[yellow]Warning: MCP config {detail}[/yellow]") + elif tracker: + tracker.complete("mcp", f"configured for {configured_detail}") # --------------------------------------------------------------------------- @@ -1803,6 +1828,29 @@ def _workspace_has_python_code(project_path: Path) -> bool: _ENCODE_RE_SUMMARY_FINISHED = re.compile(r"\[SUMMARY\] finished batch with (\d+) files") +def _encode_live_status(phase: str, started_at: float) -> Panel: + """Build the fixed-panel initial-encode status display.""" + elapsed = max(0, int(time.monotonic() - started_at)) + hours, remainder = divmod(elapsed, 3600) + minutes, seconds = divmod(remainder, 60) + now = datetime.now().astimezone().strftime("%H:%M:%S") + footer = Text.assemble( + ("Now ", "dim"), + (now, "cyan"), + (" • Elapsed ", "dim"), + (f"{hours:02d}:{minutes:02d}:{seconds:02d}", "cyan"), + ) + return Panel( + Text(phase, style="bold white", justify="center"), + title=Text("Encoding", style="bold cyan"), + title_align="center", + subtitle=footer, + subtitle_align="center", + border_style="cyan", + padding=(1, 2), + ) + + def _parse_encoder_line(line: str, state: Dict[str, Any]) -> None: """Mutate ``state`` based on a single line of encoder stderr. @@ -1821,6 +1869,9 @@ def _parse_encoder_line(line: str, state: Dict[str, Any]) -> None: * ``[SUMMARY] processing batch with N files`` / ``finished batch`` * ``Refactoring to RPG`` / ``RPG refactoring done`` """ + if "LLM returned empty response" in line or "LLM returned None at iteration" in line: + state["llm_warning_count"] = state.get("llm_warning_count", 0) + 1 + return if "Skeleton loaded" in line: state["phase"] = "Skeleton loaded" return @@ -1862,32 +1913,22 @@ def _parse_encoder_line(line: str, state: Dict[str, Any]) -> None: state["phase"] = "Parsing function batches" return if _ENCODE_RE_CLASS_PROCESS.search(line): - state["class_done"] += 1 - if state.get("class_total"): - state["class_done"] = min(state["class_done"], state["class_total"]) - state["_class_counted_on_process"] = True state["kind"] = "class" state["phase"] = "Parsing class batches" return if _ENCODE_RE_CLASS_FINISHED.search(line): - if not state.get("_class_counted_on_process"): - state["class_done"] += 1 + state["class_done"] += 1 if state.get("class_total"): state["class_done"] = min(state["class_done"], state["class_total"]) state["kind"] = "class" state["phase"] = "Parsing class batches" return if _ENCODE_RE_FUNC_PROCESS.search(line): - state["func_done"] += 1 - if state.get("func_total"): - state["func_done"] = min(state["func_done"], state["func_total"]) - state["_func_counted_on_process"] = True state["kind"] = "function" state["phase"] = "Parsing function batches" return if _ENCODE_RE_FUNC_FINISHED.search(line): - if not state.get("_func_counted_on_process"): - state["func_done"] += 1 + state["func_done"] += 1 if state.get("func_total"): state["func_done"] = min(state["func_done"], state["func_total"]) state["kind"] = "function" @@ -1945,9 +1986,9 @@ def _run_initial_encode(project_path: Path) -> bool: * Capture stderr in a reader thread and write it verbatim to ``~/.cmind/workspaces//logs/encode.log`` — power users can ``tail -f`` it for the full firehose. - * Parse a handful of phase markers off each line to drive a - :class:`rich.progress.Progress` bar with a spinner + current - phase + (when known) an M/N batch counter. + * Parse a handful of phase markers and update one live status panel with + the current phase, wall-clock time, and elapsed duration. The panel is + refreshed at most once per second and has no spinner or percentage. * Capture stdout and surface the encoder's JSON summary on failure so the user has something concrete to debug. @@ -2012,6 +2053,7 @@ def _run_initial_encode(project_path: Path) -> bool: "summary_total_files": 0, "summary_current_files": 0, "total_files": 0, + "llm_warning_count": 0, } try: @@ -2068,107 +2110,47 @@ def _stderr_reader() -> None: stdout_chunks: List[str] = [] interrupted = False - - progress = Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - BarColumn(bar_width=None), - MofNCompleteColumn(), - TimeElapsedColumn(), - console=console, - transient=False, - ) - task_id = progress.add_task(state["phase"], total=None) + started_at = time.monotonic() + started_wall_clock = datetime.now().astimezone() try: - with progress: + displayed_phase = state["phase"] + console.print( + "[dim]Started at " + f"{started_wall_clock.strftime('%Y-%m-%d %H:%M:%S %Z')}[/dim]" + ) + displayed_second = -1 + with Live( + _encode_live_status(displayed_phase, started_at), + console=console, + auto_refresh=False, + transient=True, + ) as live: while True: - kind = state["kind"] - if kind == "class" and state["class_total"]: - progress.update( - task_id, - description=state["phase"], - total=state["class_total"], - completed=state["class_done"], - ) - elif kind == "function" and state["func_total"]: - progress.update( - task_id, - description=state["phase"], - total=state["func_total"], - completed=state["func_done"], + elapsed_second = int(time.monotonic() - started_at) + if ( + state["phase"] != displayed_phase + or elapsed_second != displayed_second + ): + displayed_phase = state["phase"] + displayed_second = elapsed_second + live.update( + _encode_live_status(displayed_phase, started_at), + refresh=True, ) - elif kind == "summary" and state["summary_total"]: - progress.update( - task_id, - description=state["phase"], - total=state["summary_total"], - completed=state["summary_done"], - ) - else: - # Indeterminate phase (e.g. "Refactoring to RPG", - # "Finalising"). Update the description, but also - # unfreeze the task whenever the previous determinate - # phase ended with ``completed == total`` — Rich - # sets ``task.finished_time`` at that point, and - # ``TimeElapsedColumn`` then renders the frozen - # ``finished_time`` instead of the live ``elapsed``, - # so the timer appears stuck. We have to mutate the - # Task directly because ``Progress.update`` provides - # no public way to clear ``finished_time`` and - # ``update(total=None)`` is a no-op (None means - # "leave unchanged"). - progress.update(task_id, description=state["phase"]) - if progress.tasks: - t = progress.tasks[0] - if t.finished_time is not None: - t.total = None - t.completed = 0 - t.finished_time = None - t.finished_speed = None if proc.poll() is not None: break time.sleep(0.2) # Process exited — drain remaining stdout (JSON summary) - # and wait for the reader to consume any trailing stderr - # lines still buffered in the pipe, so the final progress - # frame reflects the *complete* phase state. + # and wait for the reader to consume any trailing stderr lines. try: if proc.stdout is not None: stdout_chunks.append(proc.stdout.read()) except Exception: # noqa: BLE001 pass reader.join(timeout=2) - # Final frame: show the *latest* batch state we know about, - # not whatever the previous polling iteration captured. If - # the encoder zipped through function batches between two - # 0.2-second polls and is now in "Finalising", we still want - # the bar to read "3/3" rather than "1/3". - if state["summary_total"]: - progress.update( - task_id, - description=state["phase"], - total=state["summary_total"], - completed=state["summary_done"], - ) - elif state["func_total"]: - progress.update( - task_id, - description=state["phase"], - total=state["func_total"], - completed=state["func_done"], - ) - elif state["class_total"]: - progress.update( - task_id, - description=state["phase"], - total=state["class_total"], - completed=state["class_done"], - ) - else: - progress.update(task_id, description=state["phase"]) except KeyboardInterrupt: interrupted = True try: @@ -2193,15 +2175,41 @@ def _stderr_reader() -> None: ) return False + elapsed_seconds = max(0, int(time.monotonic() - started_at)) + elapsed_minutes, elapsed_remainder = divmod(elapsed_seconds, 60) + elapsed_text = f"{elapsed_minutes}m {elapsed_remainder:02d}s" + finished_text = datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S %Z") + if proc.returncode == 0: console.print() + warning_count = state["llm_warning_count"] + if warning_count: + console.print( + Panel( + "[yellow]Encoder completed with LLM warnings.[/]\n\n" + "The RPG graph was written, but one or more semantic parsing " + f"calls returned no usable response ({warning_count} warning " + "log entries). Some class or function features may be missing.\n\n" + f"Finished at: [cyan]{finished_text}[/]\n" + f"Total time: [cyan]{elapsed_text}[/]\n\n" + f"Review [cyan]{log_path}[/] before relying on the graph, and " + "re-run [cyan]/cmind.encode[/] after fixing the model or proxy " + "response path.", + title="[bold yellow]Encode completed with warnings[/bold yellow]", + border_style="yellow", + padding=(1, 2), + ) + ) + return True console.print( Panel( "[green]Encoder finished successfully.[/]\n\n" "The RPG graph is now available under your home-dir " "workspace store ([cyan]rpg.json[/]). The post-commit hook will " "keep it in sync on every commit; the MCP tools " - "([cyan]search_rpg[/], [cyan]explore_rpg[/], …) are now usable.", + "([cyan]search_rpg[/], [cyan]explore_rpg[/], …) are now usable.\n\n" + f"Finished at: [cyan]{finished_text}[/]\n" + f"Total time: [cyan]{elapsed_text}[/]", title="[bold green]Encode complete[/bold green]", border_style="green", padding=(1, 2), @@ -2222,7 +2230,9 @@ def _stderr_reader() -> None: Panel( f"[red]Encoder exited with code {proc.returncode}.[/]\n\n" f"Check [cyan]{log_path}[/] for the full log. You can retry " - "with [cyan]/cmind.encode[/] after fixing the issue." + "with [cyan]/cmind.encode[/] after fixing the issue.\n\n" + f"Finished at: [cyan]{finished_text}[/]\n" + f"Total time: [cyan]{elapsed_text}[/]" f"{summary_blurb}", title="[bold red]Encode failed[/bold red]", border_style="red", @@ -2850,7 +2860,7 @@ def _install_hooks( selected_ai: str, tracker=None, ) -> None: - """Install RPG auto-update hooks for the selected AI assistant. + """Install RPG auto-update hooks for all integrated assistants. - Claude: merges a ``SessionStart`` hook into ``.claude/settings.json`` that runs ``update_graphs.py status`` so stdout (RPG stats + MCP @@ -2871,12 +2881,10 @@ def _install_hooks( """ try: installed = [] - if selected_ai == "claude": - _install_claude_hooks(project_path) - installed.append("claude") - elif selected_ai == "copilot": - _install_copilot_hooks(project_path) - installed.append("copilot") + _install_claude_hooks(project_path) + installed.append("claude") + _install_copilot_hooks(project_path) + installed.append("copilot") # Strip any leftover pre-commit block from older installs. _uninstall_git_pre_commit_hook(project_path) @@ -3248,12 +3256,12 @@ def _install_from_bundle( cmind_root = project_path / ".cmind" cmind_root.mkdir(parents=True, exist_ok=True) - # 1. Materialise slash-command templates into the AI-specific - # directory. _materialise_commands_for_agent owns the - # per-agent file-name / folder rules. - _materialise_commands_for_agent( - ai_assistant, _assets.commands_dir(), project_path - ) + # 1. Materialise every supported agent integration. ``ai_assistant`` + # selects the encoder/decoder backend, not which integrations exist. + for agent in _INTEGRATED_AGENTS: + _materialise_commands_for_agent( + agent, _assets.commands_dir(), project_path + ) # 2. Record the provisioning source so subsequent ``cmind update`` # invocations default to the same channel. @@ -3289,15 +3297,16 @@ def _materialise_commands_for_agent( Layout produced: claude → ``.claude/commands/cmind..md`` - copilot → ``.github/agents/cmind..agent.md`` + copilot → ``.github/agents/cmind..agent.md`` ``.github/prompts/cmind..prompt.md`` (frontmatter points at the corresponding agent) + codex → ``.agents/skills/cmind-/SKILL.md`` others → fallback: ``.cmind/commands/cmind..md`` (same ``cmind..md`` prefix for consistency with the supported agents above) - NOTE: ``claude`` and ``copilot`` are the only verified agents in - AGENT_CONFIG today. Add new agents here when AGENT_CONFIG grows. + NOTE: ``claude``, ``copilot``, and ``codex`` have explicit layouts. + Add new agents here when AGENT_CONFIG grows. """ def _read_body(src: Path) -> str: # Normalise CRLF → LF, matching what the CI's ``tr -d '\r'`` does. @@ -3324,6 +3333,8 @@ def _read_body(src: Path) -> str: (prompts / f"{stem}.prompt.md").write_text( f"---\nagent: {stem}\n---\n", encoding="utf-8" ) + elif ai_assistant == "codex": + _codex_skills.materialize_skills(src_commands_dir, project_path) else: # Unknown agent (init() validates against AGENT_CONFIG so this # branch is unreachable from the public CLI, but provides a @@ -3664,7 +3675,7 @@ def init( ai_assistant: str = typer.Option( None, "--ai", - help="AI assistant to use: copilot or claude", + help="Default encoder/decoder backend: copilot, claude, or codex", ), script_type: str = typer.Option( None, "--script", help="Script type to use: sh or ps" @@ -3701,7 +3712,7 @@ def init( False, "--no-copilot-cli-mcp", help=( - "When --ai copilot is selected, skip also registering " + "Skip registering " "rpg-tools globally in ~/.copilot/mcp-config.json. The " "Copilot CLI does not read workspace .vscode/mcp.json, so " "this global registration is what makes `copilot` find " @@ -3736,9 +3747,9 @@ def init( This command will: 1. Check that required tools are installed (git is optional) - 2. Let you choose your AI assistant - 3. Install command templates from the packaged bundle - 4. Place them into a new project directory or current directory + 2. Let you choose the default encoder/decoder backend + 3. Install Claude, Copilot, and Codex integrations from the bundle + 4. Place all integrations into the project 5. Initialize a fresh git repository (if not --no-git and no existing repo) 6. Optionally set up AI assistant commands @@ -3838,11 +3849,15 @@ def init( raise typer.Exit(1) selected_ai = ai_assistant else: - # Create options dict for selection (agent_key: display_name) - ai_choices = {key: config["name"] for key, config in AGENT_CONFIG.items()} - selected_ai = select_with_arrows( - ai_choices, "Choose your AI assistant:", "copilot" - ) + if sys.stdin.isatty(): + ai_choices = { + key: config["name"] for key, config in AGENT_CONFIG.items() + } + selected_ai = select_with_arrows( + ai_choices, "Choose the encoder/decoder backend:", "copilot" + ) + else: + selected_ai = "copilot" if not ignore_agent_tools: agent_config = AGENT_CONFIG.get(selected_ai) @@ -3892,7 +3907,7 @@ def init( else: selected_script = default_script - console.print(f"[cyan]Selected AI assistant:[/cyan] {selected_ai}") + console.print(f"[cyan]Selected LLM backend:[/cyan] {selected_ai}") console.print(f"[cyan]Selected script type:[/cyan] {selected_script}") tracker = StepTracker("Initialize CoderMind Project") @@ -3901,7 +3916,7 @@ def init( tracker.add("precheck", "Check required tools") tracker.complete("precheck", "ok") - tracker.add("ai-select", "Select AI assistant") + tracker.add("ai-select", "Select LLM backend") tracker.complete("ai-select", f"{selected_ai}") tracker.add("script-select", "Select script type") tracker.complete("script-select", selected_script) @@ -3966,13 +3981,10 @@ def init( else: _generate_mcp_config(project_path, selected_ai, tracker=tracker) - # Global registration for Copilot CLI (which doesn't read - # workspace .vscode/mcp.json). Skipped for non-copilot AIs, - # when --no-mcp is set, or when the user opts out explicitly. + # Global registration for Copilot CLI, independent of which + # encoder/decoder backend is active. if no_mcp: pass - elif selected_ai != "copilot": - tracker.skip("copilot-cli-mcp", f"ai={selected_ai}") elif no_copilot_cli_mcp: tracker.skip("copilot-cli-mcp", "--no-copilot-cli-mcp flag") else: @@ -4082,22 +4094,19 @@ def init( ) console.print(git_error_panel) - # Agent folder security notice - agent_config = AGENT_CONFIG.get(selected_ai) - if agent_config: - if selected_ai == "copilot": - ignored_path_desc = ".github/agents/ and .github/prompts/" - else: - ignored_path_desc = agent_config["folder"] - security_notice = Panel( - f"CoderMind's slash command definitions under [cyan]{ignored_path_desc}[/cyan] are regenerated by [cyan]cmind init/update[/cyan] and are excluded from git by default.\n" - f"Collaborators should run [cyan]cmind init[/cyan] in their clone to materialize the prompt files locally.", - title="[yellow]Agent Folder Notice[/yellow]", - border_style="yellow", - padding=(1, 2), - ) - console.print() - console.print(security_notice) + security_notice = Panel( + "CoderMind regenerates Claude commands, Copilot prompts, and Codex " + "skills under [cyan].claude/commands/[/cyan], " + "[cyan].github/{agents,prompts}/[/cyan], and " + "[cyan].agents/skills/cmind-*/[/cyan]. These generated files are " + "excluded from git by default.\nCollaborators should run " + "[cyan]cmind init .[/cyan] in their clone to materialize them locally.", + title="[yellow]Agent Integration Notice[/yellow]", + border_style="yellow", + padding=(1, 2), + ) + console.print() + console.print(security_notice) # Pre-create runtime directories so early pipeline prompts that redirect # to ~/.cmind/workspaces//logs/.log don't fail with "No such file or directory". @@ -4113,21 +4122,10 @@ def init( steps_lines.append("1. You're already in the project directory!") step_num = 2 - # Add Codex-specific setup step if needed - if selected_ai == "codex": - codex_path = project_path / ".codex" - quoted_path = shlex.quote(str(codex_path)) - if os.name == "nt": # Windows - cmd = f"setx CODEX_HOME {quoted_path}" - else: # Unix-like systems - cmd = f"export CODEX_HOME={quoted_path}" - - steps_lines.append( - f"{step_num}. Set [cyan]CODEX_HOME[/cyan] environment variable before running Codex: [cyan]{cmd}[/cyan]" - ) - step_num += 1 - - steps_lines.append(f"{step_num}. Start using high-level slash commands with your AI agent:") + steps_lines.append( + f"{step_num}. Use [cyan]/cmind.*[/cyan] in Claude/Copilot or " + "[cyan]$cmind-*[/cyan] in Codex:" + ) steps_lines.extend([ " For new projects / requirements-to-code:", @@ -4175,21 +4173,19 @@ def init( console.print() console.print(steps_panel) - # Permissions hint for .claude/ settings - if selected_ai == "claude": - claude_settings = project_path / ".claude" / "settings.json" - permissions_hint = Panel( - f"The template pre-configures [cyan].claude/settings.json[/cyan] with broad permissions " - f"(e.g. [cyan]Bash[/cyan], [cyan]Write[/cyan], [cyan]Edit[/cyan]) so that Claude Code can run scripts and " - f"modify files without repeated approval prompts.\n" - f"These permissions may be more permissive than you need. " - f"You can review and adjust them at any time by editing [cyan]{claude_settings.relative_to(project_path)}[/cyan].", - title="[yellow]Pre-granted Permissions[/yellow]", - border_style="yellow", - padding=(1, 2), - ) - console.print() - console.print(permissions_hint) + claude_settings = project_path / ".claude" / "settings.json" + permissions_hint = Panel( + f"The template pre-configures [cyan].claude/settings.json[/cyan] with broad permissions " + f"(e.g. [cyan]Bash[/cyan], [cyan]Write[/cyan], [cyan]Edit[/cyan]) so that Claude Code can run scripts and " + f"modify files without repeated approval prompts.\n" + f"These permissions may be more permissive than you need. " + f"You can review and adjust them at any time by editing [cyan]{claude_settings.relative_to(project_path)}[/cyan].", + title="[yellow]Pre-granted Permissions[/yellow]", + border_style="yellow", + padding=(1, 2), + ) + console.print() + console.print(permissions_hint) # Initialise the private snapshot repo inside .cmind/. Done BEFORE # the optional initial encode so the encoder's output, if it runs, @@ -4227,7 +4223,7 @@ def update( ai_assistant: str = typer.Option( None, "--ai", - help="AI assistant to use (auto-detected from existing project if not specified)", + help="Change the default encoder/decoder backend: copilot, claude, or codex", ), script_type: str = typer.Option( None, "--script", help="Script type to use: sh or ps" @@ -4246,7 +4242,7 @@ def update( False, "--no-copilot-cli-mcp", help=( - "When --ai copilot is selected, skip also registering " + "Skip registering " "rpg-tools globally in ~/.copilot/mcp-config.json. The " "Copilot CLI does not read workspace .vscode/mcp.json, so " "this global registration is what makes `copilot` find " @@ -4279,7 +4275,7 @@ def update( This command updates scripts, templates, command definitions, MCP config, gitignore rules, and git hooks in the current directory. - It auto-detects the AI assistant from existing project configuration. + It preserves the active encoder/decoder backend unless ``--ai`` is passed. Equivalent to re-running 'cmind init --here --force' but with proper semantics and automatic detection of existing settings. @@ -4308,7 +4304,7 @@ def update( ) raise typer.Exit(1) - # Determine AI assistant + # Determine the active encoder/decoder backend. if ai_assistant: if ai_assistant not in AGENT_CONFIG: console.print( @@ -4318,18 +4314,20 @@ def update( raise typer.Exit(1) selected_ai = ai_assistant else: - detected = _detect_ai_agent(project_path) - if detected: + try: + backend = _workspace_config.read_active_backend(project_path) + except _workspace_config.WorkspaceConfigError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise typer.Exit(1) from exc + if backend.agent is None: console.print( - f"[cyan]Auto-detected AI assistant:[/cyan] {detected} " - f"({AGENT_CONFIG[detected]['name']})" - ) - selected_ai = detected - else: - ai_choices = {key: config["name"] for key, config in AGENT_CONFIG.items()} - selected_ai = select_with_arrows( - ai_choices, "Choose your AI assistant:", "copilot" + f"[red]Error:[/red] Active backend command " + f"{backend.command!r} is custom. Re-run with " + "[cyan]--ai copilot|claude|codex[/cyan] to select a supported backend." ) + raise typer.Exit(1) + selected_ai = backend.agent + console.print(f"[cyan]Active LLM backend:[/cyan] {selected_ai}") # Determine script type if script_type: @@ -4362,7 +4360,7 @@ def update( else: selected_script = default_script - console.print(f"[cyan]Selected AI assistant:[/cyan] {selected_ai}") + console.print(f"[cyan]Selected LLM backend:[/cyan] {selected_ai}") console.print(f"[cyan]Selected script type:[/cyan] {selected_script}") # Pre-update CLI upgrade ------------------------------------------------- @@ -4487,7 +4485,7 @@ def update( sys._cmind_tracker_active = True - tracker.add("ai-select", "Select AI assistant") + tracker.add("ai-select", "Select LLM backend") tracker.complete("ai-select", f"{selected_ai}") tracker.add("script-select", "Select script type") tracker.complete("script-select", selected_script) @@ -4552,11 +4550,10 @@ def update( else: _generate_mcp_config(project_path, selected_ai, tracker=tracker) - # Global registration for Copilot CLI (see init for rationale). + # Global registration for Copilot CLI, independent of the active + # encoder/decoder backend. if no_mcp: pass - elif selected_ai != "copilot": - tracker.skip("copilot-cli-mcp", f"ai={selected_ai}") elif no_copilot_cli_mcp: tracker.skip("copilot-cli-mcp", "--no-copilot-cli-mcp flag") else: @@ -4567,6 +4564,9 @@ def update( # current post-commit/post-merge dispatcher contract. _install_hooks(project_path, selected_ai, tracker=tracker) + if ai_assistant: + _workspace_config.set_active_backend(project_path, selected_ai) + tracker.complete("final", "update complete") except Exception as e: tracker.error("final", str(e)) @@ -4602,8 +4602,8 @@ def update( "\n[bold green]CoderMind templates updated successfully.[/bold green]" ) console.print( - f"[dim]Updated: scripts, templates, and {AGENT_CONFIG[selected_ai]['name']} " - f"command definitions in [cyan]{project_path}[/cyan][/dim]" + f"[dim]Updated: scripts and Claude/Copilot/Codex integrations in " + f"[cyan]{project_path}[/cyan][/dim]" ) # Backfill inner snapshot repo for workspaces created before diff --git a/CoderMind/src/cmind_cli/_codex_config.py b/CoderMind/src/cmind_cli/_codex_config.py new file mode 100644 index 0000000..95458bb --- /dev/null +++ b/CoderMind/src/cmind_cli/_codex_config.py @@ -0,0 +1,78 @@ +"""Manage project-scoped Codex configuration owned by CoderMind.""" + +from __future__ import annotations + +import os +from pathlib import Path + +import tomlkit +from tomlkit.exceptions import ParseError + + +class CodexConfigError(ValueError): + """Raised when project Codex configuration cannot be merged safely.""" + + +def configure_rpg_tools(workspace: Path) -> Path: + """Register ``rpg-tools`` without overwriting a user-owned command.""" + path = workspace / ".codex" / "config.toml" + document = _load_or_create(path) + + servers = document.get("mcp_servers") + if servers is None: + servers = tomlkit.table() + document.add("mcp_servers", servers) + if not isinstance(servers, dict): + raise CodexConfigError(f"{path} has a non-table mcp_servers value") + + current = servers.get("rpg-tools") + if current is not None: + if not isinstance(current, dict): + raise CodexConfigError(f"{path} has an invalid rpg-tools MCP entry") + command = current.get("command") + if command and command != "cmind-mcp": + raise CodexConfigError( + f"{path} already defines rpg-tools with command {command!r}; " + "refusing to overwrite it" + ) + args = current.get("args") + if args not in (None, []): + raise CodexConfigError( + f"{path} already defines custom rpg-tools args; " + "refusing to overwrite them" + ) + else: + current = tomlkit.table() + servers["rpg-tools"] = current + + current["command"] = "cmind-mcp" + current["args"] = [] + existing_env = current.get("env") + if existing_env is not None and not isinstance(existing_env, dict): + raise CodexConfigError(f"{path} has invalid rpg-tools env configuration") + env = tomlkit.inline_table() + if isinstance(existing_env, dict): + env.update(existing_env) + env["CMIND_MCP_CLIENT_CONTEXT"] = "codex-agent" + current["env"] = env + path.parent.mkdir(parents=True, exist_ok=True) + _atomic_write(path, tomlkit.dumps(document)) + return path + + +def _load_or_create(path: Path): + if not path.exists(): + return tomlkit.document() + try: + return tomlkit.parse(path.read_text(encoding="utf-8")) + except (OSError, ParseError) as exc: + raise CodexConfigError(f"could not read {path}: {exc}") from exc + + +def _atomic_write(path: Path, content: str) -> None: + temporary = path.with_suffix(path.suffix + ".tmp") + try: + temporary.write_text(content, encoding="utf-8") + os.replace(temporary, path) + except OSError as exc: + raise CodexConfigError(f"could not write {path}: {exc}") from exc diff --git a/CoderMind/src/cmind_cli/_codex_skills.py b/CoderMind/src/cmind_cli/_codex_skills.py new file mode 100644 index 0000000..033ba45 --- /dev/null +++ b/CoderMind/src/cmind_cli/_codex_skills.py @@ -0,0 +1,136 @@ +"""Render shared CoderMind command templates as repository-scoped Codex skills.""" + +from __future__ import annotations + +import argparse +import json +import re +from dataclasses import dataclass +from pathlib import Path + +import yaml + + +_FRONTMATTER = re.compile(r"\A---\s*\n(?P.*?)\n---\s*\n?", re.DOTALL) +_COMMAND_REFERENCE = re.compile(r"/cmind\.([a-z0-9_]+)") +_VALID_SKILL_NAME = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +_INPUT_PLACEHOLDER = "" + + +class CodexSkillError(ValueError): + """Raised when a shared command template cannot become a Codex skill.""" + + +@dataclass(frozen=True) +class RenderedSkill: + """A rendered Codex skill and its destination folder name.""" + + name: str + description: str + content: str + + +def render_template(source: Path) -> RenderedSkill: + """Convert one shared command template to a Codex ``SKILL.md``.""" + text = source.read_text(encoding="utf-8") + match = _FRONTMATTER.match(text) + if match is None: + raise CodexSkillError(f"{source} is missing YAML frontmatter") + + metadata = yaml.safe_load(match.group("yaml")) + if not isinstance(metadata, dict): + raise CodexSkillError(f"{source} frontmatter must be a mapping") + + command_name = metadata.get("name") + description = metadata.get("description") + if not isinstance(command_name, str) or not command_name.startswith("cmind."): + raise CodexSkillError(f"{source} has an invalid CoderMind command name") + if not isinstance(description, str) or not description.strip(): + raise CodexSkillError(f"{source} has no skill description") + + skill_name = _skill_name(command_name) + body = match.string[match.end():] + uses_input = "$ARGUMENTS" in body + body = body.replace("$ARGUMENTS", _INPUT_PLACEHOLDER) + body = body.replace("/cmind.*", "$cmind-*") + body = _COMMAND_REFERENCE.sub( + lambda ref: f"$cmind-{ref.group(1).replace('_', '-')}", + body, + ) + + sections = [ + "---", + f"name: {skill_name}", + f"description: {json.dumps(description.strip())}", + "---", + "", + ] + if uses_input: + sections.extend( + [ + "## Invocation Input", + "", + f"Treat text accompanying `${skill_name}` as `{_INPUT_PLACEHOLDER}`. ", + "Before running a shown command, replace that placeholder with the ", + "actual input and pass it as one safely quoted shell argument. If no ", + "input accompanies the skill invocation, remove the placeholder from ", + "the command. Never execute the literal placeholder.", + "", + ] + ) + sections.append(body.lstrip()) + content = "\n".join(sections) + if not content.endswith("\n"): + content += "\n" + + return RenderedSkill( + name=skill_name, + description=description.strip(), + content=content, + ) + + +def materialize_skills(source_dir: Path, workspace: Path) -> list[Path]: + """Write all shared templates as project skills without deleting user files.""" + destinations: list[Path] = [] + for source in sorted(source_dir.glob("*.md")): + skill = render_template(source) + skill_dir = workspace / ".agents" / "skills" / skill.name + skill_dir.mkdir(parents=True, exist_ok=True) + + skill_file = skill_dir / "SKILL.md" + skill_file.write_text(skill.content, encoding="utf-8") + + agents_dir = skill_dir / "agents" + agents_dir.mkdir(parents=True, exist_ok=True) + (agents_dir / "openai.yaml").write_text( + "policy:\n allow_implicit_invocation: false\n", + encoding="utf-8", + ) + destinations.append(skill_file) + + if not destinations: + raise CodexSkillError(f"no command templates found in {source_dir}") + return destinations + + +def _skill_name(command_name: str) -> str: + name = command_name.replace(".", "-").replace("_", "-").lower() + if len(name) > 64 or _VALID_SKILL_NAME.fullmatch(name) is None: + raise CodexSkillError(f"invalid Codex skill name derived from {command_name!r}") + return name + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Render CoderMind command templates as Codex project skills." + ) + parser.add_argument("source_dir", type=Path) + parser.add_argument("workspace", type=Path) + args = parser.parse_args() + generated = materialize_skills(args.source_dir, args.workspace) + print(f"Generated {len(generated)} Codex skills in {args.workspace}") + + +if __name__ == "__main__": + main() diff --git a/CoderMind/src/cmind_cli/_workspace_config.py b/CoderMind/src/cmind_cli/_workspace_config.py new file mode 100644 index 0000000..3ebca4d --- /dev/null +++ b/CoderMind/src/cmind_cli/_workspace_config.py @@ -0,0 +1,136 @@ +"""Read and update the team-shared CoderMind workspace configuration.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + +import tomlkit +from tomlkit.exceptions import ParseError + + +AGENT_CLI_COMMANDS: dict[str, str] = { + "copilot": "copilot", + "claude": "claude", + "gemini": "gemini -p", + "qwen": "qwen -p", + "cursor-agent": "agent -p", + "auggie": "augment -p", + "codex": "codex exec", + "codebuddy": "codebuddy -p", + "qoder": "qodercli -p", + "opencode": "opencode run", + "amp": "amp --execute", +} + +SUPPORTED_BACKENDS = ("copilot", "claude", "codex") +_CONFIG_RELPATH = Path(".cmind/config.toml") +_CONFIG_HEADER = """# CoderMind workspace configuration +# Managed by `cmind init` / `cmind update`. Safe to commit. +# See: https://github.com/microsoft/RPG-ZeroRepo (CoderMind/docs/configuration.md) + +""" + + +class WorkspaceConfigError(ValueError): + """Raised when a workspace configuration cannot be read or updated.""" + + +@dataclass(frozen=True) +class ActiveBackend: + """The configured backend and its resolved CLI command.""" + + agent: str | None + command: str + + +def config_path(workspace: Path) -> Path: + return workspace / _CONFIG_RELPATH + + +def initialize(workspace: Path, agent: str) -> Path: + """Create the workspace config when missing, preserving existing files.""" + path = config_path(workspace) + if path.exists(): + return path + + command = _registered_command_for(agent) + path.parent.mkdir(parents=True, exist_ok=True) + document = tomlkit.document() + document.add("cmind", {"ai_cli_cmd": command}) + _atomic_write(path, _CONFIG_HEADER + tomlkit.dumps(document)) + return path + + +def read_active_backend(workspace: Path) -> ActiveBackend: + """Read the active backend from a CoderMind workspace.""" + path = config_path(workspace) + document = _load(path) + cmind = document.get("cmind") + command = cmind.get("ai_cli_cmd") if isinstance(cmind, dict) else None + if not isinstance(command, str) or not command.strip(): + raise WorkspaceConfigError(f"{path} does not define [cmind].ai_cli_cmd") + + normalized = command.strip() + agent = next( + ( + name + for name in SUPPORTED_BACKENDS + if AGENT_CLI_COMMANDS[name] == normalized + ), + None, + ) + return ActiveBackend(agent=agent, command=normalized) + + +def set_active_backend(workspace: Path, agent: str) -> ActiveBackend: + """Set the active backend while preserving comments and unrelated keys.""" + path = config_path(workspace) + document = _load(path) + if agent not in SUPPORTED_BACKENDS: + choices = ", ".join(SUPPORTED_BACKENDS) + raise WorkspaceConfigError( + f"unsupported backend {agent!r}; choose one of: {choices}" + ) + command = _registered_command_for(agent) + + cmind = document.get("cmind") + if cmind is None: + cmind = tomlkit.table() + document.add("cmind", cmind) + if not isinstance(cmind, dict): + raise WorkspaceConfigError(f"{path} has a non-table [cmind] value") + + cmind["ai_cli_cmd"] = command + _atomic_write(path, tomlkit.dumps(document)) + return ActiveBackend(agent=agent, command=command) + + +def _registered_command_for(agent: str) -> str: + if agent not in AGENT_CLI_COMMANDS: + choices = ", ".join(AGENT_CLI_COMMANDS) + raise WorkspaceConfigError( + f"unknown agent {agent!r}; choose one of: {choices}" + ) + return AGENT_CLI_COMMANDS[agent] + + +def _load(path: Path): + if not path.is_file(): + raise WorkspaceConfigError( + f"no CoderMind workspace config found at {path}; run `cmind init .` first" + ) + try: + return tomlkit.parse(path.read_text(encoding="utf-8")) + except (OSError, ParseError) as exc: + raise WorkspaceConfigError(f"could not read {path}: {exc}") from exc + + +def _atomic_write(path: Path, content: str) -> None: + temporary = path.with_suffix(path.suffix + ".tmp") + try: + temporary.write_text(content, encoding="utf-8") + os.replace(temporary, path) + except OSError as exc: + raise WorkspaceConfigError(f"could not write {path}: {exc}") from exc diff --git a/CoderMind/tests/test_base_class_agent_coverage.py b/CoderMind/tests/test_base_class_agent_coverage.py new file mode 100644 index 0000000..bf546e8 --- /dev/null +++ b/CoderMind/tests/test_base_class_agent_coverage.py @@ -0,0 +1,60 @@ +"""Coverage validation for base-class generation.""" +from __future__ import annotations + +import sys +from pathlib import Path + + +SCRIPTS_DIR = Path(__file__).resolve().parents[1] / "scripts" +if str(SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPTS_DIR)) + +from func_design.base_class_agent import ( # noqa: E402 + BaseClassAgent, + BaseClassOutput, + DataStructureDefinition, +) + + +class PartialCoverageLLM: + def __init__(self) -> None: + self.calls = 0 + + def call_structured(self, **kwargs): + self.calls += 1 + result = BaseClassOutput( + data_structures=[ + DataStructureDefinition( + code="class TypeA:\n pass\n", + subtree="Area", + data_flow_types=["TypeA"], + ) + ] + ) + return None, result, None + + +def test_uncovered_data_flow_types_exhaust_retries() -> None: + llm = PartialCoverageLLM() + agent = BaseClassAgent( + llm_client=llm, + max_iterations=2, + target_language="python", + ) + + result = agent.design_base_classes( + repo_name="fixture", + repo_info="fixture", + data_flow=[ + {"data_type": "TypeA"}, + {"data_type": "TypeB"}, + ], + skeleton_tree="fixture", + functional_areas=["Area"], + functional_areas_overview="Area", + project_background="", + ) + + assert llm.calls == 2 + assert result["success"] is False + assert "TypeB" in result["error"] diff --git a/CoderMind/tests/test_branch_name_sanitization.py b/CoderMind/tests/test_branch_name_sanitization.py index f28496a..136ea44 100644 --- a/CoderMind/tests/test_branch_name_sanitization.py +++ b/CoderMind/tests/test_branch_name_sanitization.py @@ -8,7 +8,10 @@ if str(SCRIPTS_DIR) not in sys.path: sys.path.insert(0, str(SCRIPTS_DIR)) -from common.git_utils import sanitize_branch_component # noqa: E402 +from code_gen import git_ops, subtree_review # noqa: E402 +from code_gen.git_ops import setup_batch_branch # noqa: E402 +from common import git_utils # noqa: E402 +from common.git_utils import GitRunner, sanitize_branch_component # noqa: E402 def test_trailing_dot_after_truncation_is_removed() -> None: @@ -17,10 +20,56 @@ def test_trailing_dot_after_truncation_is_removed() -> None: safe = sanitize_branch_component(batch_id, max_len=50, fallback="batch") - assert safe == "src_expression_calculator_syntax_expression_state" + assert safe.startswith("src_expression_calculator_syntax_") + assert safe[-9] == "-" + assert all(char in "0123456789abcdef" for char in safe[-8:]) + assert len(safe) == 50 assert not safe.endswith(".") +def test_long_batch_ids_with_shared_prefix_remain_unique() -> None: + first = "src_tasklite_cli_use_cases_manage_tasks.py_20260822_101218_97be5e0e" + second = "src_tasklite_cli_use_cases_manage_tasks.py_20260822_101218_58a347c9" + + first_safe = sanitize_branch_component(first, max_len=50, fallback="batch") + second_safe = sanitize_branch_component(second, max_len=50, fallback="batch") + + assert first_safe != second_safe + assert len(first_safe) <= 50 + assert len(second_safe) <= 50 + + +def test_retry_preserves_failed_branch_and_creates_fresh_recovery_branch(tmp_path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + git = GitRunner(str(repo)) + git.run_git(["config", "user.name", "test"]) + git.run_git(["config", "user.email", "test@example.com"]) + (repo / "README.md").write_text("base\n", encoding="utf-8") + git.stage_and_commit("initial") + original_head = git.get_head_commit() + batch_id = "docs_policies_runtime.py_20260822_100942_5f60fe86" + + created, failed_branch, _ = setup_batch_branch(git, batch_id, repo) + assert created + (repo / "README.md").write_text("failed branch\n", encoding="utf-8") + git.stage_and_commit("failed attempt") + assert git.switch_branch("main") + + recovered, recovery_branch, recovery_head = setup_batch_branch( + git, + batch_id, + repo, + preserve_existing=True, + ) + + assert recovered + assert recovery_branch == f"{failed_branch}-retry-1" + assert git.branch_exists(failed_branch) + assert recovery_head == original_head + assert git.get_current_branch() == recovery_branch + + def test_empty_and_separator_only_values_use_fallback() -> None: assert sanitize_branch_component("", fallback="batch") == "batch" assert sanitize_branch_component(" ", fallback="task") == "task" @@ -58,10 +107,6 @@ def test_result_is_idempotent() -> None: def test_all_branch_prefixes_consume_the_shared_sanitizer() -> None: # Guard against a future call site re-introducing ad-hoc truncation. - from code_gen import git_ops - from code_gen import subtree_review - from common import git_utils - for module in (git_ops, subtree_review, git_utils): source = Path(module.__file__).read_text(encoding="utf-8") assert "sanitize_branch_component" in source diff --git a/CoderMind/tests/test_code_gen_multilingual.py b/CoderMind/tests/test_code_gen_multilingual.py index ce62b59..8bb8377 100644 --- a/CoderMind/tests/test_code_gen_multilingual.py +++ b/CoderMind/tests/test_code_gen_multilingual.py @@ -110,6 +110,10 @@ def test_cpp_codegen_prompt_injects_cpp_context(monkeypatch, tmp_path: Path) -> def test_cpp_codegen_prompt_aligns_cmake_command_with_post_verify(monkeypatch, tmp_path: Path) -> None: _set_language(monkeypatch, tmp_path, "cpp") + monkeypatch.setattr( + "decoder_lang.cpp_backend.shutil.which", + lambda name: f"/usr/bin/{name}", + ) (tmp_path / "CMakeLists.txt").write_text("cmake_minimum_required(VERSION 3.16)\n") task = _task("src/tasklite_cli/task.cpp") diff --git a/CoderMind/tests/test_codex_config.py b/CoderMind/tests/test_codex_config.py new file mode 100644 index 0000000..9336ee7 --- /dev/null +++ b/CoderMind/tests/test_codex_config.py @@ -0,0 +1,74 @@ +"""Tests for safe project-scoped Codex MCP configuration.""" + +import pytest + +from cmind_cli import _codex_config + + +def test_configure_rpg_tools_preserves_existing_config(tmp_path): + path = tmp_path / ".codex" / "config.toml" + path.parent.mkdir() + path.write_text('# keep\nmodel = "custom"\n\n[other]\nvalue = 42\n') + + _codex_config.configure_rpg_tools(tmp_path) + + content = path.read_text() + assert "# keep" in content + assert 'model = "custom"' in content + assert "[other]" in content + assert "value = 42" in content + assert '[mcp_servers.rpg-tools]' in content + assert 'command = "cmind-mcp"' in content + assert 'CMIND_MCP_CLIENT_CONTEXT = "codex-agent"' in content + + +def test_configure_rpg_tools_is_idempotent(tmp_path): + path = _codex_config.configure_rpg_tools(tmp_path) + first = path.read_text() + + _codex_config.configure_rpg_tools(tmp_path) + + assert path.read_text() == first + + +def test_configure_rpg_tools_refuses_custom_command(tmp_path): + path = tmp_path / ".codex" / "config.toml" + path.parent.mkdir() + path.write_text( + '[mcp_servers.rpg-tools]\ncommand = "user-mcp"\nargs = []\n' + ) + + with pytest.raises(_codex_config.CodexConfigError, match="refusing"): + _codex_config.configure_rpg_tools(tmp_path) + + assert 'command = "user-mcp"' in path.read_text() + + +def test_configure_rpg_tools_refuses_custom_args(tmp_path): + path = tmp_path / ".codex" / "config.toml" + path.parent.mkdir() + path.write_text( + '[mcp_servers.rpg-tools]\ncommand = "cmind-mcp"\nargs = ["--custom"]\n' + ) + + with pytest.raises(_codex_config.CodexConfigError, match="custom.*args"): + _codex_config.configure_rpg_tools(tmp_path) + + assert 'args = ["--custom"]' in path.read_text() + + +def test_configure_rpg_tools_preserves_custom_env(tmp_path): + path = tmp_path / ".codex" / "config.toml" + path.parent.mkdir() + path.write_text( + '[mcp_servers.rpg-tools]\n' + 'command = "cmind-mcp"\n' + 'args = []\n' + 'env = { USER_SETTING = "keep" }\n' + ) + + _codex_config.configure_rpg_tools(tmp_path) + + content = path.read_text() + assert 'USER_SETTING = "keep"' in content + assert 'CMIND_MCP_CLIENT_CONTEXT = "codex-agent"' in content \ No newline at end of file diff --git a/CoderMind/tests/test_codex_session_manager.py b/CoderMind/tests/test_codex_session_manager.py new file mode 100644 index 0000000..f9e5b83 --- /dev/null +++ b/CoderMind/tests/test_codex_session_manager.py @@ -0,0 +1,49 @@ +"""Tests for the Codex CLI runtime adapter.""" + +import sys +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) +sys.path.insert(0, str(PROJECT_ROOT / "scripts")) + +from common.session_manager import CodexSessionManager, create_session_manager + + +def test_codex_manager_uses_stdin_and_bounded_permissions(tmp_path): + manager = create_session_manager("codex", tmp_path) + + with manager.trace("Reply with exactly: codex-ok") as context: + assert isinstance(manager, CodexSessionManager) + assert context.extra_args == [ + "--approve-for-me", + "--ephemeral", + "--skip-git-repo-check", + "-", + ] + assert context.stdin.read() == "Reply with exactly: codex-ok" + + assert context.stdin.closed + + +def test_codex_manager_rewinds_stdin_for_retry(tmp_path): + manager = create_session_manager("codex", tmp_path) + + with manager.trace("retry me") as context: + assert context.stdin.read() == "retry me" + context.refresh_for_retry() + assert context.stdin.read() == "retry me" + + +def test_codex_manager_allows_explicit_sandbox_bypass(tmp_path, monkeypatch): + monkeypatch.setenv("CMIND_CODEX_BYPASS_SANDBOX", "1") + manager = create_session_manager("codex", tmp_path) + + with manager.trace("automate me") as context: + assert context.extra_args == [ + "--dangerously-bypass-approvals-and-sandbox", + "--ephemeral", + "--skip-git-repo-check", + "-", + ] \ No newline at end of file diff --git a/CoderMind/tests/test_codex_skills.py b/CoderMind/tests/test_codex_skills.py new file mode 100644 index 0000000..c17a88a --- /dev/null +++ b/CoderMind/tests/test_codex_skills.py @@ -0,0 +1,95 @@ +"""Tests for rendering shared CoderMind commands as Codex skills.""" + +from pathlib import Path + +import yaml + +from cmind_cli import _codex_skills + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +COMMANDS_DIR = PROJECT_ROOT / "templates" / "commands" + + +def test_render_template_converts_name_input_and_command_references(): + rendered = _codex_skills.render_template(COMMANDS_DIR / "feature_edit.md") + + assert rendered.name == "cmind-feature-edit" + assert "$ARGUMENTS" not in rendered.content + assert "/cmind." not in rendered.content + assert "" in rendered.content + assert "$cmind-feature-construct" in rendered.content + assert "$cmind-plan" in rendered.content + + frontmatter = rendered.content.split("---", 2)[1] + metadata = yaml.safe_load(frontmatter) + assert metadata["name"] == "cmind-feature-edit" + assert metadata["description"] == rendered.description + + +def test_materialize_all_commands_as_explicit_project_skills(tmp_path): + generated = _codex_skills.materialize_skills(COMMANDS_DIR, tmp_path) + + assert len(generated) == len(list(COMMANDS_DIR.glob("*.md"))) == 15 + assert all(path.name == "SKILL.md" for path in generated) + assert all(path.is_file() for path in generated) + assert all("$ARGUMENTS" not in path.read_text() for path in generated) + assert all("/cmind." not in path.read_text() for path in generated) + + policy = ( + tmp_path + / ".agents" + / "skills" + / "cmind-encode" + / "agents" + / "openai.yaml" + ) + assert yaml.safe_load(policy.read_text()) == { + "policy": {"allow_implicit_invocation": False} + } + + +def test_materialize_preserves_unrelated_user_skill(tmp_path): + user_skill = tmp_path / ".agents" / "skills" / "user-owned" / "SKILL.md" + user_skill.parent.mkdir(parents=True) + user_skill.write_text("user content\n") + + _codex_skills.materialize_skills(COMMANDS_DIR, tmp_path) + + assert user_skill.read_text() == "user content\n" + + +def test_materialize_is_idempotent(tmp_path): + first = _codex_skills.materialize_skills(COMMANDS_DIR, tmp_path) + first_contents = {path: path.read_text() for path in first} + + second = _codex_skills.materialize_skills(COMMANDS_DIR, tmp_path) + + assert first == second + assert {path: path.read_text() for path in second} == first_contents + + +def test_render_rejects_template_without_frontmatter(tmp_path): + source = tmp_path / "invalid.md" + source.write_text("# Missing metadata\n") + + try: + _codex_skills.render_template(source) + except _codex_skills.CodexSkillError as exc: + assert "missing YAML frontmatter" in str(exc) + else: + raise AssertionError("invalid template was accepted") + + +def test_release_scripts_use_shared_renderer(): + repo_root = PROJECT_ROOT.parent + shell = ( + repo_root / ".github/workflows/scripts/cmind/create-release-packages.sh" + ).read_text() + powershell = ( + repo_root / ".github/workflows/scripts/cmind/create-release-packages.ps1" + ).read_text() + + for script in (shell, powershell): + assert "src/cmind_cli/_codex_skills.py" in script + assert ".codex/prompts" not in script diff --git a/CoderMind/tests/test_dep_graph.py b/CoderMind/tests/test_dep_graph.py index 22fa333..8e0f63a 100644 --- a/CoderMind/tests/test_dep_graph.py +++ b/CoderMind/tests/test_dep_graph.py @@ -504,6 +504,10 @@ def test_file_name(self, parsed_graph): name = parsed_graph.get_name("src/main.py") assert "main" in name + def test_file_name_removes_suffix_without_truncating_stem(self, parsed_graph): + parsed_graph.G.add_node("src/repository.py", type=NodeType.FILE) + assert parsed_graph.get_name("src/repository.py") == "repository" + def test_class_name(self, parsed_graph): name = parsed_graph.get_name("src/models.py:User") assert name == "User" diff --git a/CoderMind/tests/test_encode_commands.py b/CoderMind/tests/test_encode_commands.py index 1e8b5db..45dc297 100644 --- a/CoderMind/tests/test_encode_commands.py +++ b/CoderMind/tests/test_encode_commands.py @@ -499,12 +499,42 @@ def test_graph_query_engine_node_not_found(self, tmp_rpg_with_dep_graph): assert "error" in result def test_create_mcp_server_returns_server(self, tmp_rpg_with_dep_graph): - """create_mcp_server should return a FastMCP instance with 4 tools.""" + """create_mcp_server should return a FastMCP instance with RPG access.""" from mcp_server import create_mcp_server server = create_mcp_server(rpg_file=tmp_rpg_with_dep_graph) assert hasattr(server, "run") assert server.name == "rpg-tools" + def test_create_mcp_server_exposes_tree_resource(self, tmp_rpg_with_dep_graph): + from mcp_server import create_mcp_server + + server = create_mcp_server(rpg_file=tmp_rpg_with_dep_graph) + resources = server._resource_manager.list_resources() + + assert "rpg://tree" in {str(resource.uri) for resource in resources} + + def test_create_mcp_server_reloads_updated_rpg(self, tmp_rpg_with_dep_graph): + import asyncio + from mcp_server import create_mcp_server + + rpg_path = Path(tmp_rpg_with_dep_graph) + server = create_mcp_server(rpg_file=str(rpg_path)) + first = asyncio.run( + server._tool_manager.call_tool("list_rpg_tree", {"max_depth": 0}) + ) + assert json.loads(first)["name"] == "test_repo" + + updated = json.loads(rpg_path.read_text()) + updated["root"]["name"] = "updated_repo" + replacement = rpg_path.with_suffix(".replacement.json") + replacement.write_text(json.dumps(updated, indent=2)) + replacement.replace(rpg_path) + + second = asyncio.run( + server._tool_manager.call_tool("list_rpg_tree", {"max_depth": 0}) + ) + assert json.loads(second)["name"] == "updated_repo" + def test_create_mcp_server_handles_missing_rpg_file(self, tmp_path): """Server must start cleanly when rpg.json is absent. @@ -524,12 +554,39 @@ def test_create_mcp_server_handles_missing_rpg_file(self, tmp_path): assert payload["error"] == "rpg_unavailable" assert "/cmind.encode" in payload["next_step"] + def test_tool_call_telemetry_records_client_context(self, tmp_path, monkeypatch): + import mcp_server as m + + calls = tmp_path / "mcp_calls.jsonl" + monkeypatch.setattr(m, "MCP_CALLS_LOG", calls) + monkeypatch.setenv("CMIND_MCP_CLIENT_CONTEXT", "codex-agent") + + m._log_tool_call( + "list_rpg_tree", + {"max_depth": 1}, + {"total_nodes": 3}, + 4, + ) + + record = json.loads(calls.read_text(encoding="utf-8")) + assert record["client_context"] == "codex-agent" + # ============================================================================ # Test: CLI integration (M12 commands removed) # ============================================================================ class TestCLIIntegration: + def test_mcp_dependency_stays_on_fastmcp_compatible_major(self): + """MCP 2.x removes ``mcp.server.fastmcp`` used by the server.""" + import tomllib + + pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml" + with open(pyproject, "rb") as file: + dependencies = tomllib.load(file)["project"]["dependencies"] + + assert "mcp>=1.0.0,<2" in dependencies + def test_main_app_no_encode_command(self): """The main app should NOT have 'encode' registered (removed in M12 redo).""" from cmind_cli import app diff --git a/CoderMind/tests/test_hooks_install.py b/CoderMind/tests/test_hooks_install.py index 85d0af8..eb73d63 100644 --- a/CoderMind/tests/test_hooks_install.py +++ b/CoderMind/tests/test_hooks_install.py @@ -191,14 +191,14 @@ def test_install_copilot_hooks_preserves_user_tasks(project): # Dispatch # --------------------------------------------------------------------------- -def test_install_hooks_dispatches_to_copilot(project, monkeypatch): +def test_install_hooks_provisions_claude_and_copilot(project, monkeypatch): (project / ".git" / "hooks").mkdir(parents=True) cmind_cli._install_hooks(project, "copilot", tracker=None) - # Copilot tasks.json present, Claude settings.json absent. + # Both user-facing integrations coexist regardless of the active backend. assert (project / ".vscode" / "tasks.json").is_file() - assert not (project / ".claude" / "settings.json").exists() + assert (project / ".claude" / "settings.json").is_file() hooks_dir = project / ".git" / "hooks" post_commit = (hooks_dir / "post-commit").read_text() post_merge = (hooks_dir / "post-merge").read_text() @@ -209,13 +209,13 @@ def test_install_hooks_dispatches_to_copilot(project, monkeypatch): assert not (hooks_dir / "pre-commit").exists() -def test_install_hooks_dispatches_to_claude(project): +def test_install_hooks_are_backend_independent(project): (project / ".git" / "hooks").mkdir(parents=True) cmind_cli._install_hooks(project, "claude", tracker=None) assert (project / ".claude" / "settings.json").is_file() - assert not (project / ".vscode" / "tasks.json").exists() + assert (project / ".vscode" / "tasks.json").is_file() hooks_dir = project / ".git" / "hooks" assert (hooks_dir / "post-commit").is_file() assert (hooks_dir / "post-merge").is_file() @@ -534,21 +534,21 @@ def test_setup_gitignore_greenfield_writes_full_template(tmp_path): assert ".vscode/mcp.json" in content assert ".vscode/tasks.json" in content assert ".mcp.json" in content - # Copilot-specific + # All generated agent integrations coexist. assert ".github/agents/" in content assert ".github/prompts/" in content - # Claude rules must NOT leak into copilot project - assert ".claude/commands/" not in content + assert ".claude/commands/" in content + assert ".agents/skills/cmind-*/" in content -def test_setup_gitignore_greenfield_claude(tmp_path): - """Claude path uses .claude/commands/ instead of .github/*.""" +def test_setup_gitignore_is_backend_independent(tmp_path): + """Generated integration rules do not depend on the active LLM backend.""" cmind_cli._setup_gitignore(tmp_path, "claude") content = (tmp_path / ".gitignore").read_text() assert ".claude/commands/" in content - # Copilot directories must NOT be ignored on a Claude project - assert ".github/agents/" not in content - assert ".github/prompts/" not in content + assert ".github/agents/" in content + assert ".github/prompts/" in content + assert ".agents/skills/cmind-*/" in content def test_setup_gitignore_existing_git_no_ignore_writes_cmind_only(tmp_path): @@ -686,3 +686,12 @@ def test_generate_mcp_config_claude_has_no_sandbox_field(tmp_path): server = cfg["mcpServers"]["rpg-tools"] assert "sandboxEnabled" not in server assert "sandbox" not in server + + +def test_generate_mcp_config_also_writes_codex_project_config(tmp_path): + cmind_cli._generate_mcp_config(tmp_path, "copilot") + + content = (tmp_path / ".codex" / "config.toml").read_text() + assert '[mcp_servers.rpg-tools]' in content + assert 'command = "cmind-mcp"' in content + assert "args = []" in content diff --git a/CoderMind/tests/test_initial_encode_prompt.py b/CoderMind/tests/test_initial_encode_prompt.py index 0d1871b..cb29bca 100644 --- a/CoderMind/tests/test_initial_encode_prompt.py +++ b/CoderMind/tests/test_initial_encode_prompt.py @@ -194,6 +194,7 @@ def _fresh_state(): "func_total": 0, "func_done": 0, "total_files": 0, + "llm_warning_count": 0, } @@ -239,8 +240,13 @@ def test_parse_line_class_batches_and_progress(): assert s["class_total"] == 7 cmind_cli._parse_encoder_line( "RPGParser - INFO - [GLOBAL] process_class_batch: classes=['A'], units=3", s) + assert s["class_done"] == 0 + cmind_cli._parse_encoder_line( + "RPGParser - INFO - [GLOBAL] finished class batch with 3 units", s) cmind_cli._parse_encoder_line( "RPGParser - INFO - [GLOBAL] process_class_batch: classes=['B'], units=2", s) + cmind_cli._parse_encoder_line( + "RPGParser - INFO - [GLOBAL] finished class batch with 2 units", s) assert s["class_done"] == 2 @@ -252,6 +258,9 @@ def test_parse_line_function_batches_and_progress(): assert s["func_total"] == 6 cmind_cli._parse_encoder_line( "RPGParser - INFO - [GLOBAL] process_func_batch: functions=['f'], units=1", s) + assert s["func_done"] == 0 + cmind_cli._parse_encoder_line( + "RPGParser - INFO - [GLOBAL] finished function batch with 1 units", s) assert s["func_done"] == 1 @@ -270,6 +279,15 @@ def test_parse_line_unknown_is_ignored(): assert s == _fresh_state() +def test_parse_line_counts_empty_llm_warnings(): + s = _fresh_state() + cmind_cli._parse_encoder_line("LLM returned empty response", s) + cmind_cli._parse_encoder_line( + "RPGParser - ERROR - parse_functions: LLM returned None at iteration 1", s + ) + assert s["llm_warning_count"] == 2 + + # --------------------------------------------------------------------------- # _run_initial_encode — end-to-end with a mocked subprocess # --------------------------------------------------------------------------- @@ -340,3 +358,65 @@ def test_run_initial_encode_failure_returns_false(tmp_path): log = cmind_cli._storage.workspace_logs_dir(tmp_path) / "encode.log" assert log.is_file() assert "boom" in log.read_text() + + +def test_run_initial_encode_warns_on_empty_llm_responses(tmp_path, capsys): + _make_fake_encoder( + tmp_path, + exit_code=0, + stderr_lines=[ + "LLM returned empty response", + "RPGParser - ERROR - parse_functions: LLM returned None at iteration 1", + ], + stdout_text='{"status": "success"}\n', + ) + + assert cmind_cli._run_initial_encode(tmp_path) is True + output = capsys.readouterr().out + assert "completed with LLM warnings" in output + assert "2 warning log entries" in output + + +def test_run_initial_encode_uses_single_live_timer(tmp_path, monkeypatch, capsys): + _make_fake_encoder( + tmp_path, + exit_code=0, + stderr_lines=[ + "RPGParser - INFO - [GLOBAL] kind=function, groups=1, batches=1", + "RPGParser - INFO - [GLOBAL] process_func_batch: functions=['f'], units=1", + "RPGParser - INFO - [GLOBAL] finished function batch with 1 units", + ], + stdout_text='{"status": "success"}\n', + ) + + panels = [] + + class RecordingLive: + def __init__(self, renderable, **kwargs): + assert kwargs["auto_refresh"] is False + assert kwargs["transient"] is True + panels.append(renderable) + + def update(self, renderable, refresh=False): + assert refresh is True + panels.append(renderable) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, traceback): + return False + + monkeypatch.setattr(cmind_cli, "Live", RecordingLive) + + assert cmind_cli._run_initial_encode(tmp_path) is True + output = capsys.readouterr().out + assert "Started at" in output + assert "Finished at:" in output + assert "Total time:" in output + assert panels + assert all(panel.title.plain == "Encoding" for panel in panels) + assert all(panel.renderable.plain for panel in panels) + assert all("Now" in panel.subtitle.plain for panel in panels) + assert all("Elapsed" in panel.subtitle.plain for panel in panels) + assert all("/" not in panel.subtitle.plain for panel in panels) diff --git a/CoderMind/tests/test_multi_agent_provisioning.py b/CoderMind/tests/test_multi_agent_provisioning.py new file mode 100644 index 0000000..4c7e8cd --- /dev/null +++ b/CoderMind/tests/test_multi_agent_provisioning.py @@ -0,0 +1,113 @@ +"""Integration contracts for simultaneous Claude, Copilot, and Codex setup.""" + +import json +from pathlib import Path + +import tomllib + +import cmind_cli +from cmind_cli import _assets +from cmind_cli import _workspace_config + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +COMMANDS_DIR = PROJECT_ROOT / "templates" / "commands" + + +def test_bundle_materializes_all_agent_integrations(tmp_path, monkeypatch): + monkeypatch.setattr(_assets, "commands_dir", lambda: COMMANDS_DIR) + + cmind_cli._install_from_bundle( + tmp_path, + "codex", + "sh", + True, + ) + + assert len(list((tmp_path / ".claude" / "commands").glob("cmind.*.md"))) == 15 + assert len(list((tmp_path / ".github" / "agents").glob("cmind.*.agent.md"))) == 15 + assert len(list((tmp_path / ".github" / "prompts").glob("cmind.*.prompt.md"))) == 15 + assert len(list((tmp_path / ".agents" / "skills").glob("cmind-*/SKILL.md"))) == 15 + + +def test_backend_selection_does_not_change_generated_integrations(tmp_path, monkeypatch): + monkeypatch.setattr(_assets, "commands_dir", lambda: COMMANDS_DIR) + first = tmp_path / "copilot-backend" + second = tmp_path / "codex-backend" + first.mkdir() + second.mkdir() + + cmind_cli._install_from_bundle(first, "copilot", "sh", True) + cmind_cli._install_from_bundle(second, "codex", "sh", True) + + relative = lambda root: sorted( + path.relative_to(root) + for path in root.rglob("*") + if path.is_file() and ".cmind" not in path.parts + ) + assert relative(first) == relative(second) + + +def test_mcp_generation_registers_all_project_clients(tmp_path): + cmind_cli._generate_mcp_config(tmp_path, "codex") + + claude = json.loads((tmp_path / ".mcp.json").read_text()) + copilot = json.loads((tmp_path / ".vscode" / "mcp.json").read_text()) + with open(tmp_path / ".codex" / "config.toml", "rb") as file: + codex = tomllib.load(file) + + assert claude["mcpServers"]["rpg-tools"]["command"] == "cmind-mcp" + assert copilot["servers"]["rpg-tools"]["command"] == "cmind-mcp" + assert codex["mcp_servers"]["rpg-tools"]["command"] == "cmind-mcp" + + +def test_codex_config_conflict_does_not_block_other_mcp_clients(tmp_path): + codex_config = tmp_path / ".codex" / "config.toml" + codex_config.parent.mkdir() + codex_config.write_text( + '[mcp_servers.rpg-tools]\ncommand = "custom-mcp"\nargs = []\n' + ) + + tracker = cmind_cli.StepTracker("test") + tracker.add("mcp", "Configure MCP") + cmind_cli._generate_mcp_config(tmp_path, "copilot", tracker=tracker) + + claude = json.loads((tmp_path / ".mcp.json").read_text()) + copilot = json.loads((tmp_path / ".vscode" / "mcp.json").read_text()) + assert claude["mcpServers"]["rpg-tools"]["command"] == "cmind-mcp" + assert copilot["servers"]["rpg-tools"]["command"] == "cmind-mcp" + assert 'command = "custom-mcp"' in codex_config.read_text() + mcp_step = next(step for step in tracker.steps if step["key"] == "mcp") + assert mcp_step["status"] == "done" + assert "failed: codex:" in mcp_step["detail"] + + +def test_selected_codex_config_conflict_marks_mcp_step_failed(tmp_path): + codex_config = tmp_path / ".codex" / "config.toml" + codex_config.parent.mkdir() + codex_config.write_text( + '[mcp_servers.rpg-tools]\ncommand = "custom-mcp"\nargs = []\n' + ) + tracker = cmind_cli.StepTracker("test") + tracker.add("mcp", "Configure MCP") + + cmind_cli._generate_mcp_config(tmp_path, "codex", tracker=tracker) + + mcp_step = next(step for step in tracker.steps if step["key"] == "mcp") + assert mcp_step["status"] == "error" + assert "failed: codex:" in mcp_step["detail"] + assert (tmp_path / ".mcp.json").is_file() + assert (tmp_path / ".vscode" / "mcp.json").is_file() + + +def test_backend_switch_does_not_remove_integrations(tmp_path, monkeypatch): + monkeypatch.setattr(_assets, "commands_dir", lambda: COMMANDS_DIR) + cmind_cli._install_from_bundle(tmp_path, "copilot", "sh", True) + _workspace_config.initialize(tmp_path, "copilot") + + _workspace_config.set_active_backend(tmp_path, "codex") + + assert _workspace_config.read_active_backend(tmp_path).agent == "codex" + assert (tmp_path / ".claude" / "commands" / "cmind.encode.md").is_file() + assert (tmp_path / ".github" / "agents" / "cmind.encode.agent.md").is_file() + assert (tmp_path / ".agents" / "skills" / "cmind-encode" / "SKILL.md").is_file() diff --git a/CoderMind/tests/test_orphan_test_build_exclusion.py b/CoderMind/tests/test_orphan_test_build_exclusion.py index 6db2c51..7ba582b 100644 --- a/CoderMind/tests/test_orphan_test_build_exclusion.py +++ b/CoderMind/tests/test_orphan_test_build_exclusion.py @@ -30,6 +30,7 @@ from decoder_lang import get_backend # noqa: E402 from func_design.interface_review import ( # noqa: E402 _is_non_production_feature, + build_call_graph, check_call_graph_connectivity, check_feature_dependency_coverage, ) @@ -93,6 +94,50 @@ def test_case_insensitive_and_path_head(self): class TestFeatureCoverageExcludesTestBuild: + def test_qualified_method_caller_resolves_to_prefixed_unit(self): + data = { + "subtrees": { + "CLI": { + "interfaces": { + "src/cli/dispatcher.py": { + "units": ["method dispatch_list"], + "units_to_features": {"method dispatch_list": ["CLI/list"]}, + }, + "src/cli/presentation.py": { + "units": ["function build_task_views"], + "units_to_features": { + "function build_task_views": ["CLI/presentation"] + }, + }, + }, + }, + }, + } + flow = { + "invocation_edges": [{ + "caller": "CLIDispatcher.dispatch_list", + "caller_file": "src/cli/dispatcher.py", + "callee": "build_task_views", + "callee_file": "src/cli/presentation.py", + }], + "inheritance_edges": [], + "reference_edges": [], + } + + outgoing, incoming, _ = build_call_graph(data, flow) + connectivity = check_call_graph_connectivity( + data, + flow, + entry_points=[], + is_callable=get_backend("python").is_callable_unit, + ) + + caller = "src/cli/dispatcher.py::method dispatch_list" + callee = "src/cli/presentation.py::function build_task_views" + assert outgoing[caller] == {callee} + assert incoming[callee] == {caller} + assert connectivity["orphan_units"] == [] + def test_test_function_not_flagged_by_category(self): # A callable test function with no incoming edge: previously an # orphan, now excluded by the Testing category (no backend needed). diff --git a/CoderMind/tests/test_plan_orchestrator.py b/CoderMind/tests/test_plan_orchestrator.py index 60220ca..43805c6 100644 --- a/CoderMind/tests/test_plan_orchestrator.py +++ b/CoderMind/tests/test_plan_orchestrator.py @@ -94,7 +94,9 @@ def test_warning_is_treated_as_incomplete(self) -> None: states = _states(["update", "warning", "update", "update", "update"]) plan.decide(states, force=False) assert [s.will_run for s in states] == [False, True, True, True, True] - assert states[1].reason == "type=warning" + assert states[1].reason == ( + "warning: cross-stage contract violation; rebuild stage and downstream" + ) def test_force_runs_everything(self) -> None: states = _states(["update"] * 5) @@ -102,6 +104,14 @@ def test_force_runs_everything(self) -> None: assert all(s.will_run for s in states) assert all(s.reason == "forced" for s in states) + def test_force_is_forwarded_to_interfaces_stage(self) -> None: + args = plan._parse_args(["--force"]) + interfaces = next(stage for stage in plan.STAGES if stage.name == "interfaces") + skeleton = next(stage for stage in plan.STAGES if stage.name == "skeleton") + + assert "--force" in plan._build_args_for(interfaces, args) + assert "--force" not in plan._build_args_for(skeleton, args) + # --------------------------------------------------------------------------- # _extract_last_json_object — tolerant JSON parsing. diff --git a/CoderMind/tests/test_rpg_edit_code_result.py b/CoderMind/tests/test_rpg_edit_code_result.py new file mode 100644 index 0000000..0cec304 --- /dev/null +++ b/CoderMind/tests/test_rpg_edit_code_result.py @@ -0,0 +1,40 @@ +"""Persistence tests for the RPG Edit code-stage result.""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + + +SCRIPTS_DIR = Path(__file__).resolve().parents[1] / "scripts" +if str(SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPTS_DIR)) + +from rpg_edit.code import persist_code_result # noqa: E402 +from rpg_edit.review import persist_review_result # noqa: E402 + + +def test_persist_code_result_atomically_replaces_existing_result(tmp_path) -> None: + path = tmp_path / "data" / "rpg_edit_code_result.json" + path.parent.mkdir() + path.write_text('{"success": false}\n', encoding="utf-8") + result = { + "type": "code_applied", + "success": True, + "files_modified": ["src/example.py"], + "commit_sha": "abc123", + } + + persist_code_result(result, path) + + assert json.loads(path.read_text(encoding="utf-8")) == result + assert not path.with_name(f".{path.name}.tmp").exists() + + +def test_persist_review_result_creates_standard_artifact(tmp_path) -> None: + path = tmp_path / "data" / "rpg_edit_review_result.json" + result = {"type": "impact_review", "success": True, "iterations": []} + + persist_review_result(result, path) + + assert json.loads(path.read_text(encoding="utf-8")) == result \ No newline at end of file diff --git a/CoderMind/tests/test_rpg_io.py b/CoderMind/tests/test_rpg_io.py index 6cf5580..8412c48 100644 --- a/CoderMind/tests/test_rpg_io.py +++ b/CoderMind/tests/test_rpg_io.py @@ -208,6 +208,33 @@ def test_recovers_from_last_good_snapshot(self, tmp_path: Path) -> None: # No stray .tmp from the heal write. assert not (home / "data" / "rpg.json.tmp").exists() + def test_service_loader_recovers_from_last_good_snapshot(self, tmp_path: Path) -> None: + from rpg.service import RPGService + + home = _make_home_layout(tmp_path) + target = home / "data" / "rpg.json" + good = { + "repo_name": "fixture", + "root": { + "id": "fixture_L0", + "name": "fixture", + "node_type": "repo", + "level": 0, + "children": [], + }, + "edges": [], + } + rpg_io.atomic_write_rpg(target, good) + _git(home, "init", "-q", "-b", "main") + _git(home, "add", "-A") + _git(home, "commit", "-q", "-m", "valid RPG") + target.write_text('{"repo_name": "broken"', encoding="utf-8") + + service = RPGService.load(target) + + assert service.rpg.repo_name == "fixture" + assert json.loads(target.read_text(encoding="utf-8")) == good + def test_skips_bad_snapshots(self, tmp_path: Path) -> None: """If recent commits are also broken, walks further back.""" home, target, good = self._setup_with_history(tmp_path) diff --git a/CoderMind/tests/test_workspace_config.py b/CoderMind/tests/test_workspace_config.py new file mode 100644 index 0000000..01147e4 --- /dev/null +++ b/CoderMind/tests/test_workspace_config.py @@ -0,0 +1,98 @@ +"""Tests for workspace LLM backend configuration and CLI commands.""" + +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from cmind_cli import _workspace_config +from cmind_cli import app + + +runner = CliRunner() + + +def _write_config(workspace: Path, content: str) -> Path: + path = workspace / ".cmind" / "config.toml" + path.parent.mkdir(parents=True) + path.write_text(content, encoding="utf-8") + return path + + +def test_set_backend_preserves_comments_and_unrelated_values(tmp_path): + path = _write_config( + tmp_path, + """# user comment +[cmind] +ai_cli_cmd = "copilot" +custom = "keep" + +[other] +value = 42 +""", + ) + + backend = _workspace_config.set_active_backend(tmp_path, "codex") + + assert backend.agent == "codex" + assert backend.command == "codex exec" + content = path.read_text(encoding="utf-8") + assert "# user comment" in content + assert 'custom = "keep"' in content + assert "[other]" in content + assert "value = 42" in content + assert 'ai_cli_cmd = "codex exec"' in content + + +def test_initialize_is_idempotent(tmp_path): + path = _workspace_config.initialize(tmp_path, "claude") + original = path.read_text(encoding="utf-8") + + _workspace_config.initialize(tmp_path, "codex") + + assert path.read_text(encoding="utf-8") == original + assert _workspace_config.read_active_backend(tmp_path).agent == "claude" + + +def test_initialize_preserves_registered_unverified_agent_mapping(tmp_path): + _workspace_config.initialize(tmp_path, "gemini") + + assert _workspace_config.read_active_backend(tmp_path).command == "gemini -p" + + +def test_set_backend_rejects_unknown_agent(tmp_path): + _workspace_config.initialize(tmp_path, "copilot") + + with pytest.raises(_workspace_config.WorkspaceConfigError, match="unsupported"): + _workspace_config.set_active_backend(tmp_path, "unknown") + + +def test_config_show_reports_active_backend(tmp_path, monkeypatch): + _workspace_config.initialize(tmp_path, "copilot") + monkeypatch.chdir(tmp_path) + + result = runner.invoke(app, ["config", "show"]) + + assert result.exit_code == 0 + assert "Active backend: copilot" in result.output + assert "CLI command: copilot" in result.output + + +def test_config_set_agent_updates_backend(tmp_path, monkeypatch): + _workspace_config.initialize(tmp_path, "copilot") + monkeypatch.chdir(tmp_path) + + result = runner.invoke(app, ["config", "set-agent", "codex"]) + + assert result.exit_code == 0 + assert "Active backend set to codex" in result.output + assert _workspace_config.read_active_backend(tmp_path).agent == "codex" + + +def test_config_command_requires_workspace(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + + result = runner.invoke(app, ["config", "show"]) + + assert result.exit_code == 1 + assert "No CoderMind workspace found" in result.output