From 6a981a1383f8e98653c4607e97ab44725e817c66 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 06:30:03 +0000 Subject: [PATCH 1/2] docs: update ARCHITECTURE.md with install manifest (#313) feature Documents the new mapify check-installed command, .map/mapify.lock.json artifact, and install_manifest.py module in the Runtime Flows, Code Layout, On-disk Artifacts, and Source of Truth sections. Updates version to 3.21.0 and refreshes the Freshness section with the 2026-07-05 delta. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01HZ3wHDow49xBGUwWFPH2mD --- docs/ARCHITECTURE.md | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5b3bf24a..a6cb1462 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -8,7 +8,7 @@ Deep technical documentation for MAP (Modular Agentic Planner) implementation. MAP is a Python 3.11+ CLI (`mapify`) plus provider-specific prompt/skill scaffolding that turns interactive coding agents (Claude Code and Codex CLI) into a repeatable engineering workflow: `SPEC -> PLAN -> TEST -> CODE -> REVIEW -> LEARN`. It emphasizes explicit artifacts, small reviewable contracts, deterministic prompt/runtime guardrails, and post-run learning handoffs persisted alongside the project. -The current package is `mapify-cli` `3.10.0`. It ships a Typer CLI, provider delivery helpers, shared workflow-state and verification utilities, bundled Claude/Codex templates, hook scripts, cross-session memory helpers, skill-evaluation utilities, and tests that validate template contracts, artifact schemas, prompt tone, provider surfaces, workflow gates, memory hooks, skill-eval behavior, and token-budget behavior. +The current package is `mapify-cli` `3.21.0`. It ships a Typer CLI, provider delivery helpers, shared workflow-state and verification utilities, bundled Claude/Codex templates, hook scripts, cross-session memory helpers, skill-evaluation utilities, install manifest/lock auditing, and tests that validate template contracts, artifact schemas, prompt tone, provider surfaces, workflow gates, memory hooks, skill-eval behavior, token-budget behavior, and install integrity. The remainder of this file contains the deeper implementation dive (workflow-specific agent sequences, artifact specs, MCP integration, template maintenance, and context engineering). @@ -66,7 +66,7 @@ The remainder of this file contains the deeper implementation dive (workflow-spe - `src/mapify_cli/memory/`: cross-session scratch capture, digest schema, finalize, and recall helpers used by generated hooks and `/map-memory-now` - `src/mapify_cli/skills_eval/`: skill trigger eval runner, assertions, aggregation, Claude dispatcher, description optimizer, patcher, proposer, schema, and HTML viewer - `src/mapify_cli/templates/`: Shipped provider templates, hooks, agents, references, rule files, Codex config, and shared `.map/scripts/` payloads used by `mapify init` -- `src/mapify_cli/{token_budget,workflow_state,workflow_finalizer,verification_recorder,skill_ir,dependency_graph,repo_insight,_locking}.py`: Deterministic helpers used by templates, release checks, locks, and tests +- `src/mapify_cli/{token_budget,workflow_state,workflow_finalizer,verification_recorder,skill_ir,dependency_graph,repo_insight,_locking,install_manifest}.py`: Deterministic helpers used by templates, release checks, locks, install auditing, and tests - `tests/`: Unit and integration coverage for CLI behavior, generated templates, hooks, workflow artifacts, SkillIR, provider frontmatter, and artifact schemas - `docs/`: Workflow docs, deep dives, and planning history @@ -78,12 +78,14 @@ The remainder of this file contains the deeper implementation dive (workflow-spe - `.codex/`: Codex CLI config, hooks, and TOML agents generated by `mapify init . --provider codex` - `.map//`: Branch-scoped run artifacts (plans/contracts, check outputs, review notes, learning handoffs, session-memory digests) - `.map/eval-runs//`: durable skill-evaluation run logs and optimization JSON/HTML reports +- `.map/mapify.lock.json`: Install manifest/lock — aggregate audit of all MAP-managed files, written by `mapify init` and read by `mapify check-installed` Claude skill metadata includes `skillClass` in `.claude/skills/skill-rules.json` so the runtime contract is explicit: `task` skills behave like manual slash workflows or opt-in interactive task surfaces, `reference` skills provide inline guidance, and `hybrid` skills combine reference material with declared runtime effects. Today the MAP slash surfaces are `task` skills, including `/map-understand`, whose checklist is transient in the conversation and has no runtime effects; `map-state` is `hybrid` because it documents planning state and ships hooks/scripts that interact with `.map//` artifacts, and `map-so-search` is `hybrid` because it ships a script with declared network/credential runtime effects (the opt-in SOFA search; see [Stack Overflow for Agents (SOFA) Integration](#stack-overflow-for-agents-sofa-integration)). ## Runtime Flows -- **Initialize**: `mapify init` selects a provider, copies templates, and writes provider-specific prompts/skills plus shared `.map/` scripts. +- **Initialize**: `mapify init` selects a provider, copies templates, and writes provider-specific prompts/skills plus shared `.map/` scripts. At the end of init, `build_manifest()` scans the installed provider directories for all MAP-MANAGED files and writes `.map/mapify.lock.json` — recording each file's path, SHA-256 content/template hash, management mode (`fenced`/`full`/`hooks-merge`), and install timestamp. This manifest is the audit baseline for `mapify check-installed`. +- **Audit Install**: `mapify check-installed [project-path]` reads `.map/mapify.lock.json` and compares it against the current filesystem. It reports missing files (in manifest, absent on disk), drifted files (template_hash changed — a newer MAP template is available), orphaned files (MAP-managed on disk but not in the manifest), and ok files (present and matching). Exit codes: 0=all ok, 1=issues found, 2=no manifest. Security invariants: no absolute paths are stored in the manifest; `settings.local.json` (machine-specific statusline config) is excluded from the committed manifest; symlinks are excluded from scanning. - **Run Workflow**: User triggers MAP commands (e.g., `/map-plan`, `/map-efficient`, `/map-check`, `/map-review`, `/map-learn`, `/map-understand`) through the provider UI, or `$map-*` skills for Codex. Each command orchestrates a specific agent sequence or teaching loop defined in generated skill/template files. - **Apply Minimality Doctrine**: `.map/config.yaml` controls `minimality` (`off`, `lite`, `full`, `ultra`). The global default is `lite` (Phase 3 flip, #183) for ALL projects — keyless configs that previously loaded as `off` now resolve to `lite` at both the `MapConfig` and runner `_load_minimality_level` layers; set `minimality: off` to opt out (bare `off` is YAML-coerced to a boolean and normalized back to the `off` level so opt-out is not silently lost). Runtime prompt builders inject the doctrine into Actor context, Evaluator scores `simplicity` while keeping `completeness` highest-weight, Monitor distinguishes real scope/risk drift from harmless implementation size, and the orchestrator forwards only BLOCKER-class retry feedback back to Actor. Decomposer blueprints classify active subtasks with `requiredness`/`pruneable`; only `full`/`ultra` may carry a non-empty `deferred_yagni` parking lot, and plan approval must expose those omissions plus restore hints before execution. If the user restores an omission, `restore_deferred_yagni` rewrites `blueprint.json` and the task plan before approval continues. `run_health_report.json` records the historical minimality level for each workflow, and `mapify minimality-report` compares complete `off` and opt-in cohorts, reports sample gaps and cohort branch names, lists next telemetry actions, and emits a candidate-only manual review gate before maintainers consider the Phase 3 global default flip. - **Persist Artifacts**: Each workflow stage records durable artifacts under `.map//`, including specs, blueprints, test contracts, verification summaries, review bundles, learning handoffs, token-budget reports, run-health reports, and retry quarantine state. Research/discovery uses a single namespace: plan-scope discovery is `.map//research/plan__discovery.md`, and subtask-scope artifacts are `.map//research/__.md`; legacy `findings_.md` files are compatibility fallbacks, not the primary source. @@ -101,6 +103,7 @@ Claude skill metadata includes `skillClass` in `.claude/skills/skill-rules.json` - **Deterministic helpers**: `src/mapify_cli/*` helper modules and `.map/scripts/` templates enforce artifact schemas, workflow state, prompt budgets, SkillIR checks, memory finalization, skill-eval assertions, and prior-stage validation. - **Host-path and lock contract**: `src/mapify_cli/_locking.py` owns the `flock_with_state` implementation; `src/mapify_cli/templates/references/host-paths.md` is the shipped user-facing reference for `MAP_*`, `~/.map/`, and lock state-marker semantics. - **Spec citation gate**: `.map/scripts/validate_spec_citations.py` and its template twin validate `file:line` references before `/map-plan` decomposes work. +- **Install manifest**: `.map/mapify.lock.json` is the audit lock written by `mapify init` via `src/mapify_cli/install_manifest.py`; `mapify check-installed` reads it to detect missing/drifted/orphaned MAP-managed files. Security invariants: no absolute paths, no secrets, machine-local `settings.local.json` excluded from the committed manifest. - **Documentation**: `README.md`, `docs/USAGE.md`, `docs/INSTALL.md`, and this document define expected behavior and invariants. ## Worktree Isolation (per-subtask sandboxing) @@ -392,9 +395,9 @@ Information not available in current evidence. ## Freshness -Last refreshed: 2026-06-05 +Last refreshed: 2026-07-05 -Refresh reason: Daily architecture refresh after committed cross-session memory hooks, host-conditional skill installation, and `mapify skill-eval` run/optimize/view work changed the top-level MAP runtime contract. +Refresh reason: Incremental refresh after install manifest/lock (#313) added `src/mapify_cli/install_manifest.py`, `mapify check-installed` command, and `.map/mapify.lock.json` to the system contract. Evidence source files: - `README.md` @@ -403,6 +406,7 @@ Evidence source files: - `docs/INSTALL.md` - `src/mapify_cli/` - `src/mapify_cli/_locking.py` +- `src/mapify_cli/install_manifest.py` - `src/mapify_cli/delivery/providers.py` - `src/mapify_cli/workflow_state.py` - `.claude/references/host-paths.md` @@ -420,14 +424,22 @@ Evidence source files: - `Makefile` - `tests/` -Current delta captured: MAP now documents and tests cross-session memory capture +Current delta captured: install manifest/lock (#313) — `mapify init` now scans +all installed provider directories for MAP-MANAGED metadata at the end of +initialization and writes `.map/mapify.lock.json` recording content hash, +template hash, management mode (`fenced`/`full`/`hooks-merge`), and timestamp for +every managed file. `mapify check-installed` compares the current filesystem +against the manifest and reports missing/drifted/orphaned files with appropriate +exit codes (0=ok, 1=issues, 2=no manifest). Security invariants: no absolute +paths or secrets stored; `settings.local.json` excluded; symlinks excluded. +28 tests in `tests/test_install_manifest.py` cover all verification criteria. + +Earlier delta (2026-06-05): MAP documents and tests cross-session memory capture and recall through generated hooks plus `/map-memory-now`; host-conditional installation prunes skills whose commands are unavailable; `map-skill-eval` is a measurement-only skill backed by `mapify skill-eval run`; `mapify skill-eval optimize` uses train/test splits, overfit rejection, proposer iterations, -optional template patching, and HTML reports; and skill-eval fixtures/tests now -cover run logs, optimizer schemas, patch safety, viewer rendering, and the -no-Anthropic invariant across optimizer modules. +optional template patching, and HTML reports. ## Table of Contents From 43840f3dedc439c362fcab14a04c7f732599a408 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 06:45:25 +0000 Subject: [PATCH 2/2] feat(install_manifest): add config-entry ownership tracking and mapify uninstall (#314) Track MAP-owned provider config merges (MCP servers, statusline) in .map/mapify.lock.json under a new config_entries list, enabling safe removal via `mapify uninstall`. - Add ConfigEntry dataclass: file (relative), key_path (dot-notation), installed_at, mapify_version - Add ReconcileResult dataclass: removed/skipped/missing lists - Add _scan_mcp_config_entries: records MCP servers whose current value exactly matches the canonical MAP config (user-modified entries excluded) - Add _scan_statusline_config_entry: records statusLine only when command contains the "map-statusline.py" ownership marker - Add reconcile_config: reads config_entries from manifest, removes MAP- owned entries (skips user-modified, marks missing absent ones) - Extend build_manifest for claude provider to scan and record config entries; codex provider gets empty list - Backward compat: manifests without config_entries field default to [] - Add `mapify uninstall` CLI command with --yes flag for non-interactive use - Add 26 new tests covering VC10-VC17 (all pass, full suite 3309/0) Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01HZ3wHDow49xBGUwWFPH2mD --- src/mapify_cli/__init__.py | 80 +++++++ src/mapify_cli/install_manifest.py | 256 ++++++++++++++++++++- tests/test_install_manifest.py | 356 ++++++++++++++++++++++++++++- 3 files changed, 689 insertions(+), 3 deletions(-) diff --git a/src/mapify_cli/__init__.py b/src/mapify_cli/__init__.py index 7588e168..81cca116 100644 --- a/src/mapify_cli/__init__.py +++ b/src/mapify_cli/__init__.py @@ -1661,6 +1661,86 @@ def check_installed( raise typer.Exit(1) +@app.command() +def uninstall( + project_path: Optional[Path] = typer.Argument( + None, + help="Project root directory (defaults to current directory).", + ), + yes: bool = typer.Option( + False, + "--yes", + "-y", + help="Skip confirmation prompt.", + ), +) -> None: + """Remove MAP-owned config entries from provider config files. + + Reads .map/mapify.lock.json and removes only the config-merge entries + MAP injected (MCP server keys in .mcp.json, the MAP statusline in + settings.local.json). User-modified or user-owned entries are + preserved. Installed files (.claude/, .map/scripts/, etc.) are + NOT removed by this command. + + Exit codes: 0 = ok / nothing to remove, 1 = error, 2 = no manifest. + """ + from mapify_cli.install_manifest import read_manifest, reconcile_config + + target = project_path or Path.cwd() + + manifest = read_manifest(target) + if manifest is None: + console.print( + f"[yellow]No install manifest found at " + f"{target / '.map' / 'mapify.lock.json'}[/yellow]" + ) + console.print("[dim]Run [cyan]mapify init .[/cyan] to generate the manifest.[/dim]") + raise typer.Exit(2) + + if not manifest.config_entries: + console.print("[green]No MAP-owned config entries in the manifest.[/green]") + return + + console.print( + f"[bold]MAP config entries to remove[/bold] " + f"(provider: [cyan]{manifest.provider}[/cyan]):" + ) + for entry in manifest.config_entries: + console.print(f" [dim]{entry.file}[/dim] [cyan]{entry.key_path}[/cyan]") + + console.print() + if not yes: + confirm = typer.confirm( + "Remove these MAP-owned config entries?", + default=False, + ) + if not confirm: + console.print("[dim]Aborted.[/dim]") + return + + result = reconcile_config(target) + + if result.removed: + console.print(f"[green]Removed ({len(result.removed)}):[/green]") + for label in result.removed: + console.print(f" [green]✓[/green] {label}") + + if result.skipped: + console.print( + f"[yellow]Skipped ({len(result.skipped)}) — user-modified, preserved:[/yellow]" + ) + for label in result.skipped: + console.print(f" [yellow]~[/yellow] {label}") + + if result.missing: + console.print(f"[dim]Already absent ({len(result.missing)}):[/dim]") + for label in result.missing: + console.print(f" [dim]-[/dim] {label}") + + if not result.removed and not result.skipped and not result.missing: + console.print("[dim]Nothing to do.[/dim]") + + # Research localization eval commands diff --git a/src/mapify_cli/install_manifest.py b/src/mapify_cli/install_manifest.py index 33547238..b4e98e8d 100644 --- a/src/mapify_cli/install_manifest.py +++ b/src/mapify_cli/install_manifest.py @@ -5,11 +5,17 @@ file installed by the provider so the installed surface can be audited as a whole rather than file-by-file. +Also records out-of-band ownership of config-merge entries (MCP servers, +statusline) via ``config_entries`` so ``mapify uninstall`` can safely +remove only MAP-owned keys without touching user config. + Security invariants: - No absolute paths stored in the committed manifest. - Local-only files (statusline, machine-specific state) are excluded from the committed manifest. - No secrets or credential paths are written here. +- Provider strict-schema files (e.g. .mcp.json) are never given extra + metadata keys (see #270); ownership is tracked out-of-band here. """ from __future__ import annotations @@ -103,14 +109,32 @@ class ManifestEntry: installed_at: str # ISO 8601 UTC timestamp from the MAP-MANAGED header +@dataclass +class ConfigEntry: + """One MAP-owned config-merge entry (e.g. an MCP server key or statusLine). + + These live in files MAP merges INTO rather than files MAP fully owns. + Ownership is tracked out-of-band here so provider strict schemas are + never given extra metadata keys (see #270 guardrail). + No absolute paths or secrets are stored. + """ + + file: str # relative POSIX path of the config file (e.g. ".mcp.json") + key_path: str # dot-notation path to the owned key + # e.g. "mcpServers.sequential-thinking" or "statusLine" + installed_at: str # ISO 8601 UTC timestamp (manifest write time) + mapify_version: str # mapify-cli version that wrote this entry + + @dataclass class InstallManifest: """Aggregate install lock for all MAP-managed provider surfaces.""" mapify_version: str provider: str - installed_at: str # manifest write timestamp + installed_at: str # manifest write timestamp entries: list[ManifestEntry] = field(default_factory=list) + config_entries: list[ConfigEntry] = field(default_factory=list) @dataclass @@ -123,6 +147,15 @@ class CheckResult: ok: list[str] = field(default_factory=list) # present and matching +@dataclass +class ReconcileResult: + """Outcome of reconcile_config().""" + + removed: list[str] = field(default_factory=list) # MAP-owned entries removed + skipped: list[str] = field(default_factory=list) # user-modified, preserved + missing: list[str] = field(default_factory=list) # already absent from disk + + # --------------------------------------------------------------------------- # Management-mode inference # --------------------------------------------------------------------------- @@ -229,6 +262,209 @@ def _scan_file(project_path: Path, rel_file: str) -> Optional[ManifestEntry]: return _build_entry_from_file(project_path, project_path / rel_file) +# --------------------------------------------------------------------------- +# Config-entry scanning (out-of-band ownership for config-merge surfaces) +# --------------------------------------------------------------------------- + + +def _scan_mcp_config_entries( + project_path: Path, version: str, timestamp: str +) -> list[ConfigEntry]: + """Detect MAP-owned mcpServers entries in .mcp.json. + + An entry is MAP-owned when its key name is in MAP's canonical server list + AND its current value exactly matches MAP's expected config. User- + modified or user-pre-existing entries with different values are excluded. + No absolute paths are stored. + """ + try: + from mapify_cli.config.mcp import build_standard_mcp_servers, read_project_mcp_json + except ImportError: + return [] + + mcp_data = read_project_mcp_json(project_path / ".mcp.json") + if not mcp_data: + return [] + + existing_servers = mcp_data.get("mcpServers", {}) + map_servers = build_standard_mcp_servers() + + entries: list[ConfigEntry] = [] + for name, expected_config in map_servers.items(): + if existing_servers.get(name) == expected_config: + entries.append(ConfigEntry( + file=".mcp.json", + key_path=f"mcpServers.{name}", + installed_at=timestamp, + mapify_version=version, + )) + return entries + + +def _scan_statusline_config_entry( + project_path: Path, version: str, timestamp: str +) -> Optional[ConfigEntry]: + """Detect if MAP's statusline is present in settings.local.json. + + MAP-owned statusline entries always invoke map-statusline.py; that + substring is the ownership marker. No absolute path is stored in + the manifest — only the key path (``statusLine``). + """ + settings_local = project_path / ".claude" / "settings.local.json" + if not settings_local.is_file() or settings_local.is_symlink(): + return None + try: + data = json.loads(settings_local.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + if not isinstance(data, dict): + return None + status_line = data.get("statusLine") + if not isinstance(status_line, dict): + return None + command = status_line.get("command", "") + if "map-statusline.py" not in command: + return None + return ConfigEntry( + file=".claude/settings.local.json", + key_path="statusLine", + installed_at=timestamp, + mapify_version=version, + ) + + +# --------------------------------------------------------------------------- +# Config-entry reconcile (uninstall MAP-owned config keys) +# --------------------------------------------------------------------------- + + +def _remove_mcp_server(project_path: Path, server_name: str) -> bool: + """Remove a MAP-owned MCP server from .mcp.json. + + Refuses to remove if the current value differs from MAP's canonical + config (i.e. the user modified it). Returns True when the entry was + removed; False when it was absent or user-modified. + """ + try: + from mapify_cli.config.mcp import ( + build_standard_mcp_servers, + read_project_mcp_json, + write_project_mcp_json, + ) + except ImportError: + return False + + mcp_json_path = project_path / ".mcp.json" + mcp_data = read_project_mcp_json(mcp_json_path) + if not mcp_data: + return False + + servers = mcp_data.get("mcpServers", {}) + if server_name not in servers: + return False # already gone + + expected = build_standard_mcp_servers().get(server_name) + if servers[server_name] != expected: + return False # user-modified: refuse to remove + + del servers[server_name] + mcp_data["mcpServers"] = servers + write_project_mcp_json(mcp_json_path, mcp_data) + return True + + +def _remove_statusline(project_path: Path, rel_file: str) -> bool: + """Remove MAP's statusLine key from the given settings file. + + Only removes when the command contains ``map-statusline.py``. + Returns True when removed; False when absent or user-defined. + """ + settings_path = project_path / rel_file + if not settings_path.is_file() or settings_path.is_symlink(): + return False + try: + data = json.loads(settings_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return False + if not isinstance(data, dict): + return False + + sl = data.get("statusLine") + if not isinstance(sl, dict): + return False + if "map-statusline.py" not in sl.get("command", ""): + return False # user-defined statusline: refuse to remove + + del data["statusLine"] + settings_path.write_text( + json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" + ) + return True + + +def reconcile_config(project_path: Path) -> ReconcileResult: + """Remove all MAP-owned config entries recorded in the install manifest. + + For each ``ConfigEntry`` in the manifest: + - If the entry is absent from disk: recorded as ``missing``. + - If the value was user-modified (differs from MAP's canonical): recorded + as ``skipped`` (ownership preserved, key left intact). + - If the value still matches MAP's canonical: removed and recorded as + ``removed``. + + Returns a :class:`ReconcileResult` describing what happened. + """ + result = ReconcileResult() + manifest = read_manifest(project_path) + if manifest is None: + return result + + for entry in manifest.config_entries: + label = f"{entry.file}:{entry.key_path}" + + if entry.file == ".mcp.json" and entry.key_path.startswith("mcpServers."): + server_name = entry.key_path[len("mcpServers."):] + try: + from mapify_cli.config.mcp import ( + build_standard_mcp_servers, + read_project_mcp_json, + ) + mcp_data = read_project_mcp_json(project_path / ".mcp.json") + except ImportError: + result.missing.append(label) + continue + + if not mcp_data or server_name not in mcp_data.get("mcpServers", {}): + result.missing.append(label) + elif mcp_data["mcpServers"][server_name] != build_standard_mcp_servers().get(server_name): + result.skipped.append(label) + else: + _remove_mcp_server(project_path, server_name) + result.removed.append(label) + + elif entry.key_path == "statusLine": + settings_path = project_path / entry.file + if not settings_path.is_file(): + result.missing.append(label) + continue + try: + data = json.loads(settings_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + result.missing.append(label) + continue + + sl = data.get("statusLine") if isinstance(data, dict) else None + if not isinstance(sl, dict): + result.missing.append(label) + elif "map-statusline.py" not in sl.get("command", ""): + result.skipped.append(label) + else: + _remove_statusline(project_path, entry.file) + result.removed.append(label) + + return result + + # --------------------------------------------------------------------------- # Manifest building # --------------------------------------------------------------------------- @@ -266,11 +502,24 @@ def build_manifest( # Stable sort: alphabetical by dest path entries.sort(key=lambda e: e.dest) + timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + # Config-entry ownership (only for providers that do config merges) + config_entries: list[ConfigEntry] = [] + if provider == "claude": + config_entries.extend(_scan_mcp_config_entries(project_path, version, timestamp)) + ce = _scan_statusline_config_entry(project_path, version, timestamp) + if ce is not None: + config_entries.append(ce) + # Stable sort: alphabetical by file+key_path + config_entries.sort(key=lambda e: (e.file, e.key_path)) + return InstallManifest( mapify_version=version, provider=provider, - installed_at=datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + installed_at=timestamp, entries=entries, + config_entries=config_entries, ) @@ -306,11 +555,14 @@ def read_manifest(project_path: Path) -> Optional[InstallManifest]: try: raw_entries = data.get("entries", []) entries = [ManifestEntry(**e) for e in raw_entries if isinstance(e, dict)] + raw_config = data.get("config_entries", []) + config_entries = [ConfigEntry(**e) for e in raw_config if isinstance(e, dict)] return InstallManifest( mapify_version=data.get("mapify_version", ""), provider=data.get("provider", ""), installed_at=data.get("installed_at", ""), entries=entries, + config_entries=config_entries, ) except (TypeError, KeyError): return None diff --git a/tests/test_install_manifest.py b/tests/test_install_manifest.py index d22337ef..23d267d7 100644 --- a/tests/test_install_manifest.py +++ b/tests/test_install_manifest.py @@ -1,4 +1,4 @@ -"""Tests for the install manifest/lock (issue #313). +"""Tests for the install manifest/lock (issues #313 and #314). Covers: VC1: Claude install writes manifest with correct entries. @@ -10,6 +10,14 @@ VC7: read_manifest returns None for missing/corrupt manifest. VC8: management_mode is inferred correctly (fenced vs full vs hooks-merge). VC9: Local-only paths are excluded from the committed manifest. + VC10 (#314): Config entries for MCP servers are detected and recorded. + VC11 (#314): Config entries for statusline are detected and recorded. + VC12 (#314): User-modified MCP servers are NOT recorded as MAP-owned. + VC13 (#314): reconcile_config removes MAP-owned MCP server entry. + VC14 (#314): reconcile_config preserves user-modified MCP server. + VC15 (#314): reconcile_config removes MAP-owned statusline. + VC16 (#314): reconcile_config refuses to remove user-defined statusline. + VC17 (#314): read_manifest backward-compat — old manifest without config_entries. """ from __future__ import annotations @@ -31,9 +39,12 @@ InstallManifest, _build_entry_from_file, _infer_management_mode, + _scan_mcp_config_entries, + _scan_statusline_config_entry, build_manifest, check_installed, read_manifest, + reconcile_config, write_manifest, ) @@ -535,3 +546,346 @@ def test_no_manifest_returns_empty_check_result(self, tmp_path: Path) -> None: assert result.orphaned == [] assert result.drifted == [] assert result.ok == [] + + +# --------------------------------------------------------------------------- +# VC10: Config entries for MCP servers are detected +# --------------------------------------------------------------------------- + +_MAP_SERVER_NAME = "sequential-thinking" +_MAP_SERVER_CONFIG = { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-sequential-thinking"], +} +_TIMESTAMP = "2026-07-05T00:00:00Z" + + +def _write_mcp_json(project: Path, servers: dict) -> None: + """Write .mcp.json with the given mcpServers dict.""" + (project / ".mcp.json").write_text( + __import__("json").dumps({"mcpServers": servers}, indent=2) + "\n", + encoding="utf-8", + ) + + +def _write_statusline_local(project: Path, command: str) -> None: + """Write .claude/settings.local.json with the given statusLine command.""" + settings = project / ".claude" / "settings.local.json" + settings.parent.mkdir(parents=True, exist_ok=True) + settings.write_text( + __import__("json").dumps( + {"statusLine": {"type": "command", "command": command}}, indent=2 + ) + "\n", + encoding="utf-8", + ) + + +class TestVC10McpConfigEntries: + def test_map_server_detected_when_value_matches(self, tmp_path: Path) -> None: + _write_mcp_json(tmp_path, {_MAP_SERVER_NAME: _MAP_SERVER_CONFIG}) + entries = _scan_mcp_config_entries(tmp_path, VERSION, _TIMESTAMP) + assert len(entries) == 1 + e = entries[0] + assert e.file == ".mcp.json" + assert e.key_path == f"mcpServers.{_MAP_SERVER_NAME}" + assert e.installed_at == _TIMESTAMP + assert e.mapify_version == VERSION + + def test_no_mcp_json_returns_empty(self, tmp_path: Path) -> None: + entries = _scan_mcp_config_entries(tmp_path, VERSION, _TIMESTAMP) + assert entries == [] + + def test_empty_mcp_servers_returns_empty(self, tmp_path: Path) -> None: + _write_mcp_json(tmp_path, {}) + entries = _scan_mcp_config_entries(tmp_path, VERSION, _TIMESTAMP) + assert entries == [] + + def test_build_manifest_includes_mcp_config_entries(self, tmp_path: Path) -> None: + _setup_claude_install(tmp_path) + _write_mcp_json(tmp_path, {_MAP_SERVER_NAME: _MAP_SERVER_CONFIG}) + manifest = build_manifest(tmp_path, "claude", VERSION) + assert len(manifest.config_entries) >= 1 + keys = [e.key_path for e in manifest.config_entries] + assert f"mcpServers.{_MAP_SERVER_NAME}" in keys + + def test_config_entries_empty_for_codex_provider(self, tmp_path: Path) -> None: + _setup_codex_install(tmp_path) + # Even if .mcp.json exists, codex provider doesn't own MCP entries + _write_mcp_json(tmp_path, {_MAP_SERVER_NAME: _MAP_SERVER_CONFIG}) + manifest = build_manifest(tmp_path, "codex", VERSION) + assert manifest.config_entries == [] + + def test_config_entry_has_no_absolute_paths(self, tmp_path: Path) -> None: + _write_mcp_json(tmp_path, {_MAP_SERVER_NAME: _MAP_SERVER_CONFIG}) + entries = _scan_mcp_config_entries(tmp_path, VERSION, _TIMESTAMP) + for e in entries: + assert not e.file.startswith("/"), "file must be relative" + assert not e.key_path.startswith("/"), "key_path must not contain absolute path" + + +# --------------------------------------------------------------------------- +# VC11: Config entries for statusline are detected +# --------------------------------------------------------------------------- + +class TestVC11StatuslineConfigEntry: + def test_map_statusline_detected(self, tmp_path: Path) -> None: + _write_statusline_local(tmp_path, '"/abs/path/to/.claude/hooks/map-statusline.py"') + entry = _scan_statusline_config_entry(tmp_path, VERSION, _TIMESTAMP) + assert entry is not None + assert entry.file == ".claude/settings.local.json" + assert entry.key_path == "statusLine" + assert entry.installed_at == _TIMESTAMP + assert entry.mapify_version == VERSION + + def test_no_settings_local_returns_none(self, tmp_path: Path) -> None: + entry = _scan_statusline_config_entry(tmp_path, VERSION, _TIMESTAMP) + assert entry is None + + def test_user_defined_statusline_returns_none(self, tmp_path: Path) -> None: + _write_statusline_local(tmp_path, "my-custom-statusline.sh") + entry = _scan_statusline_config_entry(tmp_path, VERSION, _TIMESTAMP) + assert entry is None + + def test_missing_statusline_key_returns_none(self, tmp_path: Path) -> None: + settings = tmp_path / ".claude" / "settings.local.json" + settings.parent.mkdir(parents=True, exist_ok=True) + settings.write_text('{"theme": "dark"}\n', encoding="utf-8") + entry = _scan_statusline_config_entry(tmp_path, VERSION, _TIMESTAMP) + assert entry is None + + def test_statusline_not_a_dict_returns_none(self, tmp_path: Path) -> None: + settings = tmp_path / ".claude" / "settings.local.json" + settings.parent.mkdir(parents=True, exist_ok=True) + settings.write_text('{"statusLine": "some-string"}\n', encoding="utf-8") + entry = _scan_statusline_config_entry(tmp_path, VERSION, _TIMESTAMP) + assert entry is None + + def test_build_manifest_includes_statusline_config_entry(self, tmp_path: Path) -> None: + _setup_claude_install(tmp_path) + _write_statusline_local(tmp_path, '"/path/to/map-statusline.py"') + manifest = build_manifest(tmp_path, "claude", VERSION) + keys = [e.key_path for e in manifest.config_entries] + assert "statusLine" in keys + + +# --------------------------------------------------------------------------- +# VC12: User-modified MCP servers are NOT recorded as MAP-owned +# --------------------------------------------------------------------------- + +class TestVC12UserModifiedMcp: + def test_user_modified_server_not_recorded(self, tmp_path: Path) -> None: + user_config = {"command": "npx", "args": ["-y", "my-custom-version"]} + _write_mcp_json(tmp_path, {_MAP_SERVER_NAME: user_config}) + entries = _scan_mcp_config_entries(tmp_path, VERSION, _TIMESTAMP) + assert entries == [], "user-modified config must not be recorded as MAP-owned" + + def test_extra_user_server_not_recorded(self, tmp_path: Path) -> None: + _write_mcp_json(tmp_path, { + _MAP_SERVER_NAME: _MAP_SERVER_CONFIG, + "user-custom-server": {"command": "node", "args": ["server.js"]}, + }) + entries = _scan_mcp_config_entries(tmp_path, VERSION, _TIMESTAMP) + keys = [e.key_path for e in entries] + assert f"mcpServers.{_MAP_SERVER_NAME}" in keys + assert "mcpServers.user-custom-server" not in keys + + +# --------------------------------------------------------------------------- +# VC13: reconcile_config removes MAP-owned MCP server entry +# --------------------------------------------------------------------------- + +class TestVC13ReconcileMcpRemove: + def test_removes_map_owned_server(self, tmp_path: Path) -> None: + _setup_claude_install(tmp_path) + _write_mcp_json(tmp_path, {_MAP_SERVER_NAME: _MAP_SERVER_CONFIG}) + manifest = build_manifest(tmp_path, "claude", VERSION) + write_manifest(tmp_path, manifest) + + result = reconcile_config(tmp_path) + assert f".mcp.json:mcpServers.{_MAP_SERVER_NAME}" in result.removed + + # Verify the server was actually removed from disk + mcp_json = __import__("json").loads( + (tmp_path / ".mcp.json").read_text(encoding="utf-8") + ) + assert _MAP_SERVER_NAME not in mcp_json.get("mcpServers", {}) + + def test_preserves_other_top_level_keys(self, tmp_path: Path) -> None: + (tmp_path / ".mcp.json").write_text( + __import__("json").dumps({ + "mcpServers": {_MAP_SERVER_NAME: _MAP_SERVER_CONFIG}, + "globalShortcut": "Cmd+Shift+.", + }, indent=2) + "\n", + encoding="utf-8", + ) + manifest = build_manifest(tmp_path, "claude", VERSION) + write_manifest(tmp_path, manifest) + + reconcile_config(tmp_path) + + mcp_json = __import__("json").loads( + (tmp_path / ".mcp.json").read_text(encoding="utf-8") + ) + assert "globalShortcut" in mcp_json, "user top-level keys must be preserved" + + def test_no_manifest_returns_empty_result(self, tmp_path: Path) -> None: + result = reconcile_config(tmp_path) + assert result.removed == [] + assert result.skipped == [] + assert result.missing == [] + + def test_already_absent_server_is_missing(self, tmp_path: Path) -> None: + _write_mcp_json(tmp_path, {_MAP_SERVER_NAME: _MAP_SERVER_CONFIG}) + manifest = build_manifest(tmp_path, "claude", VERSION) + write_manifest(tmp_path, manifest) + + # Remove the server before reconciling + (tmp_path / ".mcp.json").write_text( + '{"mcpServers": {}}\n', encoding="utf-8" + ) + + result = reconcile_config(tmp_path) + assert f".mcp.json:mcpServers.{_MAP_SERVER_NAME}" in result.missing + assert result.removed == [] + + +# --------------------------------------------------------------------------- +# VC14: reconcile_config preserves user-modified MCP server +# --------------------------------------------------------------------------- + +class TestVC14ReconcilePreservesUserModified: + def test_user_modified_server_skipped(self, tmp_path: Path) -> None: + # Install with MAP config + _write_mcp_json(tmp_path, {_MAP_SERVER_NAME: _MAP_SERVER_CONFIG}) + manifest = build_manifest(tmp_path, "claude", VERSION) + write_manifest(tmp_path, manifest) + + # User later modifies the server config + user_modified = {"command": "npx", "args": ["-y", "my-custom-version"]} + _write_mcp_json(tmp_path, {_MAP_SERVER_NAME: user_modified}) + + result = reconcile_config(tmp_path) + assert f".mcp.json:mcpServers.{_MAP_SERVER_NAME}" in result.skipped + assert result.removed == [] + + # Verify the user's config is still on disk + mcp_json = __import__("json").loads( + (tmp_path / ".mcp.json").read_text(encoding="utf-8") + ) + assert mcp_json["mcpServers"][_MAP_SERVER_NAME] == user_modified + + +# --------------------------------------------------------------------------- +# VC15: reconcile_config removes MAP-owned statusline +# --------------------------------------------------------------------------- + +class TestVC15ReconcileStatuslineRemove: + def test_removes_map_owned_statusline(self, tmp_path: Path) -> None: + _setup_claude_install(tmp_path) + _write_statusline_local(tmp_path, '"/path/to/map-statusline.py"') + manifest = build_manifest(tmp_path, "claude", VERSION) + write_manifest(tmp_path, manifest) + + result = reconcile_config(tmp_path) + assert ".claude/settings.local.json:statusLine" in result.removed + + # Verify the statusLine key was removed from disk + settings = __import__("json").loads( + (tmp_path / ".claude" / "settings.local.json").read_text(encoding="utf-8") + ) + assert "statusLine" not in settings + + def test_preserves_other_settings_keys(self, tmp_path: Path) -> None: + settings = tmp_path / ".claude" / "settings.local.json" + settings.parent.mkdir(parents=True, exist_ok=True) + settings.write_text( + __import__("json").dumps({ + "statusLine": {"type": "command", "command": '"/path/map-statusline.py"'}, + "permissions": {"allow": ["Bash"]}, + }, indent=2) + "\n", + encoding="utf-8", + ) + manifest = build_manifest(tmp_path, "claude", VERSION) + write_manifest(tmp_path, manifest) + + reconcile_config(tmp_path) + + remaining = __import__("json").loads( + settings.read_text(encoding="utf-8") + ) + assert "permissions" in remaining, "user settings keys must be preserved" + assert "statusLine" not in remaining + + +# --------------------------------------------------------------------------- +# VC16: reconcile_config refuses to remove user-defined statusline +# --------------------------------------------------------------------------- + +class TestVC16ReconcileRefusesUserStatusline: + def test_user_defined_statusline_skipped(self, tmp_path: Path) -> None: + # Build manifest with MAP statusline + _write_statusline_local(tmp_path, '"/path/to/map-statusline.py"') + manifest = build_manifest(tmp_path, "claude", VERSION) + write_manifest(tmp_path, manifest) + + # User replaces the statusline with their own + _write_statusline_local(tmp_path, "my-custom-status-command") + + result = reconcile_config(tmp_path) + assert ".claude/settings.local.json:statusLine" in result.skipped + assert result.removed == [] + + def test_no_statusline_key_is_missing(self, tmp_path: Path) -> None: + # Build manifest that expects a statusLine + _write_statusline_local(tmp_path, '"/path/to/map-statusline.py"') + manifest = build_manifest(tmp_path, "claude", VERSION) + write_manifest(tmp_path, manifest) + + # User removes the key manually + settings = tmp_path / ".claude" / "settings.local.json" + settings.write_text('{"theme": "dark"}\n', encoding="utf-8") + + result = reconcile_config(tmp_path) + assert ".claude/settings.local.json:statusLine" in result.missing + assert result.removed == [] + + +# --------------------------------------------------------------------------- +# VC17: read_manifest backward compatibility — old manifest without config_entries +# --------------------------------------------------------------------------- + +class TestVC17BackwardCompat: + def test_old_manifest_without_config_entries_readable(self, tmp_path: Path) -> None: + old_manifest = { + "mapify_version": "3.10.0", + "provider": "claude", + "installed_at": "2026-01-01T00:00:00Z", + "entries": [], + # config_entries deliberately absent (old format) + } + map_dir = tmp_path / ".map" + map_dir.mkdir(parents=True, exist_ok=True) + (map_dir / "mapify.lock.json").write_text( + __import__("json").dumps(old_manifest, indent=2) + "\n", + encoding="utf-8", + ) + + manifest = read_manifest(tmp_path) + assert manifest is not None + assert manifest.mapify_version == "3.10.0" + assert manifest.config_entries == [], "old manifests must deserialize with empty config_entries" + + def test_idempotent_build_does_not_duplicate_config_entries(self, tmp_path: Path) -> None: + _write_mcp_json(tmp_path, {_MAP_SERVER_NAME: _MAP_SERVER_CONFIG}) + _write_statusline_local(tmp_path, '"/path/map-statusline.py"') + + m1 = build_manifest(tmp_path, "claude", VERSION) + write_manifest(tmp_path, m1) + + m2 = build_manifest(tmp_path, "claude", VERSION) + write_manifest(tmp_path, m2) + + m_read = read_manifest(tmp_path) + assert m_read is not None + config_keys = [e.key_path for e in m_read.config_entries] + assert len(config_keys) == len(set(config_keys)), "no duplicate config entries"