code-agnostic 0.3.18__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/PKG-INFO +1 -1
  2. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/__init__.py +1 -1
  3. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/agents/claude.py +1 -1
  4. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/agents/codex.py +2 -2
  5. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/agents/parser.py +2 -2
  6. code_agnostic-0.5.0/code_agnostic/apps/apps_service.py +294 -0
  7. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/apps/claude/service.py +4 -10
  8. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/apps/codex/config_repository.py +2 -2
  9. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/apps/codex/service.py +16 -15
  10. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/apps/common/framework.py +2 -2
  11. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/apps/common/interfaces/service.py +45 -5
  12. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/apps/common/schema.py +1 -1
  13. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/apps/common/utils.py +36 -0
  14. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/apps/copilot/service.py +4 -5
  15. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/apps/cursor/service.py +4 -5
  16. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/apps/opencode/config_repository.py +11 -2
  17. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/apps/opencode/service.py +21 -9
  18. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/cli/aliases.py +3 -1
  19. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/cli/commands/apps.py +7 -2
  20. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/cli/commands/workspaces.py +2 -1
  21. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/cli/helpers.py +9 -2
  22. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/cli/options.py +17 -6
  23. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/core/repository.py +4 -0
  24. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/executor.py +23 -8
  25. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/imports/adapters.py +6 -2
  26. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/imports/service.py +13 -10
  27. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/models.py +1 -0
  28. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/planner.py +65 -67
  29. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/project_artifacts.py +6 -2
  30. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/rules/compilers.py +2 -1
  31. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/rules/parser.py +2 -1
  32. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/skills/parser.py +6 -3
  33. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/status.py +4 -4
  34. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/tui/renderers.py +3 -1
  35. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/tui/sections.py +5 -1
  36. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/tui/tables.py +6 -5
  37. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic.egg-info/PKG-INFO +1 -1
  38. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic.egg-info/SOURCES.txt +2 -0
  39. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/pyproject.toml +6 -1
  40. code_agnostic-0.5.0/tests/test_cli_apply_mcp_cleanup.py +102 -0
  41. code_agnostic-0.5.0/tests/test_cli_disable_cleanup.py +69 -0
  42. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/tests/test_common_repository.py +2 -0
  43. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/tests/test_project_config_sync.py +7 -1
  44. code_agnostic-0.3.18/code_agnostic/apps/apps_service.py +0 -139
  45. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/LICENSE +0 -0
  46. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/README.md +0 -0
  47. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/__main__.py +0 -0
  48. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/agents/__init__.py +0 -0
  49. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/agents/compilers.py +0 -0
  50. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/agents/copilot.py +0 -0
  51. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/agents/models.py +0 -0
  52. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/agents/opencode.py +0 -0
  53. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/apps/__init__.py +0 -0
  54. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/apps/app_id.py +0 -0
  55. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/apps/claude/__init__.py +0 -0
  56. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/apps/claude/config_repository.py +0 -0
  57. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/apps/claude/mapper.py +0 -0
  58. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/apps/codex/__init__.py +0 -0
  59. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/apps/codex/mapper.py +0 -0
  60. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/apps/codex/schema.json +0 -0
  61. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/apps/codex/schema_repository.py +0 -0
  62. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/apps/common/__init__.py +0 -0
  63. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/apps/common/compiled_planning.py +0 -0
  64. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/apps/common/interfaces/__init__.py +0 -0
  65. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/apps/common/interfaces/mapper.py +0 -0
  66. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/apps/common/interfaces/repositories.py +0 -0
  67. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/apps/common/loader.py +0 -0
  68. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/apps/common/models.py +0 -0
  69. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/apps/common/symlink_planning.py +0 -0
  70. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/apps/copilot/__init__.py +0 -0
  71. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/apps/copilot/config_repository.py +0 -0
  72. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/apps/copilot/mapper.py +0 -0
  73. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/apps/cursor/__init__.py +0 -0
  74. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/apps/cursor/config_repository.py +0 -0
  75. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/apps/cursor/mapper.py +0 -0
  76. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/apps/cursor/schema.json +0 -0
  77. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/apps/cursor/schema_repository.py +0 -0
  78. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/apps/opencode/__init__.py +0 -0
  79. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/apps/opencode/mapper.py +0 -0
  80. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/apps/opencode/schema.json +0 -0
  81. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/apps/opencode/schema_repository.py +0 -0
  82. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/cli/__init__.py +0 -0
  83. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/cli/commands/__init__.py +0 -0
  84. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/cli/commands/agents.py +0 -0
  85. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/cli/commands/apply.py +0 -0
  86. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/cli/commands/explain_lossiness.py +0 -0
  87. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/cli/commands/import_.py +0 -0
  88. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/cli/commands/mcp.py +0 -0
  89. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/cli/commands/plan.py +0 -0
  90. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/cli/commands/projects.py +0 -0
  91. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/cli/commands/restore.py +0 -0
  92. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/cli/commands/rules.py +0 -0
  93. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/cli/commands/skills.py +0 -0
  94. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/cli/commands/status.py +0 -0
  95. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/cli/commands/validate.py +0 -0
  96. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/constants.py +0 -0
  97. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/core/__init__.py +0 -0
  98. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/core/project_repository.py +0 -0
  99. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/core/workspace_repository.py +0 -0
  100. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/errors.py +0 -0
  101. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/generated_artifacts.py +0 -0
  102. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/git_exclude_service.py +0 -0
  103. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/imports/__init__.py +0 -0
  104. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/imports/filesystem.py +0 -0
  105. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/imports/models.py +0 -0
  106. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/lossiness.py +0 -0
  107. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/mcp_service.py +0 -0
  108. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/rules/__init__.py +0 -0
  109. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/rules/models.py +0 -0
  110. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/rules/repository.py +0 -0
  111. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/skills/__init__.py +0 -0
  112. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/skills/compilers.py +0 -0
  113. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/skills/install_sources.py +0 -0
  114. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/skills/models.py +0 -0
  115. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/spec/__init__.py +0 -0
  116. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/spec/loaders.py +0 -0
  117. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/spec/schemas/agent.v1.schema.json +0 -0
  118. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/spec/schemas/mcp.base.schema.json +0 -0
  119. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/spec/schemas/mcp.v1.schema.json +0 -0
  120. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/spec/schemas/rule.v1.schema.json +0 -0
  121. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/spec/schemas/skill.v1.schema.json +0 -0
  122. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/tui/__init__.py +0 -0
  123. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/tui/enums.py +0 -0
  124. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/tui/import_selector.py +0 -0
  125. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/utils.py +0 -0
  126. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/validation.py +0 -0
  127. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/workspace_artifacts.py +0 -0
  128. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic/workspaces.py +0 -0
  129. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic.egg-info/dependency_links.txt +0 -0
  130. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic.egg-info/entry_points.txt +0 -0
  131. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic.egg-info/requires.txt +0 -0
  132. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/code_agnostic.egg-info/top_level.txt +0 -0
  133. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/setup.cfg +0 -0
  134. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/tests/test_cli_agents.py +0 -0
  135. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/tests/test_cli_aliases.py +0 -0
  136. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/tests/test_cli_apply_apps.py +0 -0
  137. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/tests/test_cli_apply_codex.py +0 -0
  138. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/tests/test_cli_apply_cursor.py +0 -0
  139. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/tests/test_cli_apply_target.py +0 -0
  140. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/tests/test_cli_apps.py +0 -0
  141. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/tests/test_cli_explain_lossiness.py +0 -0
  142. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/tests/test_cli_flags.py +0 -0
  143. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/tests/test_cli_git_exclude.py +0 -0
  144. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/tests/test_cli_import.py +0 -0
  145. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/tests/test_cli_import_interactive.py +0 -0
  146. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/tests/test_cli_mcp.py +0 -0
  147. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/tests/test_cli_module_organization.py +0 -0
  148. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/tests/test_cli_plan.py +0 -0
  149. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/tests/test_cli_projects.py +0 -0
  150. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/tests/test_cli_restore.py +0 -0
  151. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/tests/test_cli_rules.py +0 -0
  152. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/tests/test_cli_skills.py +0 -0
  153. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/tests/test_cli_status.py +0 -0
  154. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/tests/test_cli_validate.py +0 -0
  155. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/tests/test_cli_workspace_resolution.py +0 -0
  156. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/tests/test_cli_workspaces.py +0 -0
  157. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/tests/test_common_mcp_to_dto.py +0 -0
  158. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/tests/test_compiled_planning.py +0 -0
  159. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/tests/test_dto_to_common_mcp.py +0 -0
  160. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/tests/test_git_exclude_service.py +0 -0
  161. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/tests/test_mcp_service.py +0 -0
  162. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/tests/test_planner_executor.py +0 -0
  163. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/tests/test_planner_rules.py +0 -0
  164. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/tests/test_skill_install_sources.py +0 -0
  165. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/tests/test_symlink_planning.py +0 -0
  166. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/tests/test_sync_plan_model.py +0 -0
  167. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/tests/test_transactional_executor.py +0 -0
  168. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/tests/test_utils.py +0 -0
  169. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/tests/test_version.py +0 -0
  170. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/tests/test_workspace_config_sync.py +0 -0
  171. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/tests/test_workspace_repo_status.py +0 -0
  172. {code_agnostic-0.3.18 → code_agnostic-0.5.0}/tests/test_workspaces.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-agnostic
3
- Version: 0.3.18
3
+ Version: 0.5.0
4
4
  Summary: Centralized hub for LLM coding config: MCP, skills, rules, and agents.
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -1,3 +1,3 @@
1
1
  __all__ = ["__version__"]
2
2
 
3
- __version__ = "0.3.18"
3
+ __version__ = "0.5.0"
@@ -6,7 +6,7 @@ import re
6
6
  from pathlib import Path
7
7
  from typing import Any
8
8
 
9
- import yaml # type: ignore[import-untyped]
9
+ import yaml
10
10
 
11
11
  from code_agnostic.agents.models import Agent
12
12
 
@@ -7,9 +7,9 @@ from pathlib import Path
7
7
  from typing import Any
8
8
 
9
9
  try:
10
- import tomllib
10
+ import tomllib # type: ignore[import-not-found]
11
11
  except ModuleNotFoundError: # pragma: no cover
12
- import tomli as tomllib # type: ignore
12
+ import tomli as tomllib
13
13
 
14
14
  import tomlkit
15
15
 
@@ -83,7 +83,7 @@ def parse_agent(path: Path) -> Agent:
83
83
 
84
84
 
85
85
  def serialize_agent(agent: Agent, *, target_app: str | None = None) -> str:
86
- fm: dict = {}
86
+ fm: dict[str, Any] = {}
87
87
  if agent.metadata.name:
88
88
  fm["name"] = agent.metadata.name
89
89
  if agent.metadata.description:
@@ -105,7 +105,7 @@ def serialize_agent(agent: Agent, *, target_app: str | None = None) -> str:
105
105
  if nickname_candidates:
106
106
  fm["nickname_candidates"] = list(nickname_candidates)
107
107
 
108
- tools: dict = {}
108
+ tools: dict[str, Any] = {}
109
109
  if agent.metadata.tools.read is not True:
110
110
  tools["read"] = agent.metadata.tools.read
111
111
  if agent.metadata.tools.write is not True:
@@ -0,0 +1,294 @@
1
+ from pathlib import Path
2
+ from typing import Any
3
+
4
+ from code_agnostic.apps.app_id import AppId, app_ids_by_capability, app_scope
5
+ from code_agnostic.apps.common.framework import (
6
+ create_registered_app_service,
7
+ list_registered_app_services,
8
+ )
9
+ from code_agnostic.apps.common.interfaces.service import IAppConfigService
10
+ from code_agnostic.apps.common.symlink_planning import (
11
+ load_state_links,
12
+ load_state_paths,
13
+ plan_stale_files_group,
14
+ plan_stale_group,
15
+ )
16
+ from code_agnostic.core.project_repository import ProjectConfigRepository
17
+ from code_agnostic.core.repository import CoreRepository
18
+ from code_agnostic.core.workspace_repository import WorkspaceConfigRepository
19
+ from code_agnostic.executor import SyncExecutor
20
+ from code_agnostic.models import Action, AppStatusRow, AppSyncStatus, SyncPlan
21
+ from code_agnostic.planner import SyncPlanner
22
+ from code_agnostic.project_artifacts import load_project_entries, project_config_dir
23
+ from code_agnostic.utils import read_json_safe, write_json
24
+
25
+
26
+ class AppsService:
27
+ def __init__(self, core_repository: CoreRepository) -> None:
28
+ self.core_repository = core_repository
29
+
30
+ @property
31
+ def apps_path(self) -> Path:
32
+ return self.core_repository.root / "config" / "apps.json"
33
+
34
+ def available_apps(self) -> list[str]:
35
+ registered = set(list_registered_app_services())
36
+ manageable = set(app_ids_by_capability(toggleable=True))
37
+ return [
38
+ app.value
39
+ for app in sorted(registered & manageable, key=lambda item: item.value)
40
+ ]
41
+
42
+ def load_apps(self) -> dict[str, bool]:
43
+ payload, error = read_json_safe(self.apps_path)
44
+ if error is not None or not isinstance(payload, dict):
45
+ return self._default_apps()
46
+
47
+ result = self._default_apps()
48
+ for app_name in self.available_apps():
49
+ value = payload.get(app_name)
50
+ if isinstance(value, bool):
51
+ result[app_name] = value
52
+ return result
53
+
54
+ def save_apps(self, apps: dict[str, bool]) -> None:
55
+ normalized = self._default_apps()
56
+ for app_name in self.available_apps():
57
+ value = apps.get(app_name)
58
+ if isinstance(value, bool):
59
+ normalized[app_name] = value
60
+ write_json(self.apps_path, normalized)
61
+
62
+ def is_enabled(self, app_name: str) -> bool:
63
+ return self.load_apps().get(app_name, False)
64
+
65
+ def set_enabled(self, app_name: str, enabled: bool) -> None:
66
+ if app_name not in self.available_apps():
67
+ raise ValueError(f"Unknown app: {app_name}")
68
+ apps = self.load_apps()
69
+ apps[app_name] = enabled
70
+ self.save_apps(apps)
71
+
72
+ def enable(self, app_name: str) -> None:
73
+ self.set_enabled(app_name=app_name, enabled=True)
74
+
75
+ def disable(self, app_name: str) -> tuple[int, int, list[str]]:
76
+ """Disable an app AND clean up everything it previously synced.
77
+
78
+ Removes tracked skills/agents/compiled files and prunes the MCP servers
79
+ we own from the app's shared config, across global, workspace, and
80
+ project scopes, then clears the corresponding state. Without this,
81
+ disabling would strand orphaned artifacts with no way to reclaim them.
82
+ """
83
+ normalized = app_name.lower()
84
+ plan = self._plan_app_cleanup(normalized)
85
+ result: tuple[int, int, list[str]] = (0, 0, [])
86
+ if plan.actions:
87
+ result = SyncExecutor(core=self.core_repository).execute(
88
+ plan, persist_state=True
89
+ )
90
+ self.set_enabled(app_name=normalized, enabled=False)
91
+ return result
92
+
93
+ def _plan_app_cleanup(self, app_name: str) -> SyncPlan:
94
+ try:
95
+ app_id = AppId(app_name)
96
+ except ValueError:
97
+ return SyncPlan([], [], [])
98
+
99
+ actions: list[Action] = []
100
+ skipped: list[str] = []
101
+ core = self.core_repository
102
+
103
+ # --- Global scopes (app:<app>:*) ---
104
+ global_state = core.load_state()
105
+ global_links = self._normalize_group(global_state.get("managed_links"))
106
+ global_paths = self._normalize_group(global_state.get("managed_paths"))
107
+ global_mcp = self._normalize_group(global_state.get("managed_mcp"))
108
+ global_prefix = f"app:{app_id.value}:"
109
+ for scope in sorted(s for s in global_links if s.startswith(global_prefix)):
110
+ actions.extend(self._cleanup_links(global_links, scope, app_id.value))
111
+ for scope in sorted(s for s in global_paths if s.startswith(global_prefix)):
112
+ actions.extend(
113
+ self._cleanup_paths(global_paths, scope, app_id.value, skipped)
114
+ )
115
+ actions.extend(self._cleanup_global_mcp(app_id, global_mcp))
116
+
117
+ # --- Workspace scopes (ws:<app>:*) ---
118
+ ws_prefix = f"ws:{app_id.value}:"
119
+ for workspace in core.load_workspaces():
120
+ ws_name = workspace["name"]
121
+ ws_repo = WorkspaceConfigRepository(root=core.workspace_config_dir(ws_name))
122
+ ws_state = ws_repo.load_state()
123
+ ws_links = self._normalize_group(ws_state.get("managed_links"))
124
+ ws_paths = self._normalize_group(ws_state.get("managed_paths"))
125
+ for scope in sorted(s for s in ws_links if s.startswith(ws_prefix)):
126
+ for action in self._cleanup_links(ws_links, scope, "workspace"):
127
+ action.workspace = ws_name
128
+ actions.append(action)
129
+ for scope in sorted(s for s in ws_paths if s.startswith(ws_prefix)):
130
+ for action in self._cleanup_paths(
131
+ ws_paths, scope, "workspace", skipped
132
+ ):
133
+ action.workspace = ws_name
134
+ actions.append(action)
135
+
136
+ # --- Project scopes (project:<app>:*) ---
137
+ project_prefix = f"project:{app_id.value}:"
138
+ for project in load_project_entries(core):
139
+ project_name = project["name"]
140
+ project_repo = ProjectConfigRepository(
141
+ root=project_config_dir(core, project_name)
142
+ )
143
+ project_state = project_repo.load_state()
144
+ project_links = self._normalize_group(project_state.get("managed_links"))
145
+ project_paths = self._normalize_group(project_state.get("managed_paths"))
146
+ for scope in sorted(
147
+ s for s in project_links if s.startswith(project_prefix)
148
+ ):
149
+ for action in self._cleanup_links(project_links, scope, app_id.value):
150
+ action.project = project_name
151
+ actions.append(action)
152
+ for scope in sorted(
153
+ s for s in project_paths if s.startswith(project_prefix)
154
+ ):
155
+ for action in self._cleanup_paths(
156
+ project_paths, scope, app_id.value, skipped
157
+ ):
158
+ action.project = project_name
159
+ actions.append(action)
160
+
161
+ return SyncPlan(actions=actions, errors=[], skipped=skipped)
162
+
163
+ @staticmethod
164
+ def _normalize_group(value: Any) -> dict[str, Any]:
165
+ return (
166
+ {scope: paths for scope, paths in value.items() if isinstance(scope, str)}
167
+ if isinstance(value, dict)
168
+ else {}
169
+ )
170
+
171
+ @staticmethod
172
+ def _cleanup_links(group: dict[str, Any], scope: str, app: str) -> list[Action]:
173
+ return plan_stale_group(
174
+ old_links=load_state_links(group, scope),
175
+ desired_links=[],
176
+ remove_detail=f"remove {scope} symlink on disable",
177
+ conflict_detail="managed path is not a symlink",
178
+ noop_detail="managed symlink already absent",
179
+ app=app,
180
+ scope=scope,
181
+ skipped=[],
182
+ skipped_message="Disable cleanup skipped (not symlink): {path}",
183
+ )
184
+
185
+ @staticmethod
186
+ def _cleanup_paths(
187
+ group: dict[str, Any], scope: str, app: str, skipped: list[str]
188
+ ) -> list[Action]:
189
+ return plan_stale_files_group(
190
+ old_paths=load_state_paths(group, scope),
191
+ desired_paths=[],
192
+ remove_detail=f"remove {scope} file on disable",
193
+ conflict_detail="managed path is not a file",
194
+ noop_detail="managed file already absent",
195
+ app=app,
196
+ scope=scope,
197
+ skipped=skipped,
198
+ skipped_message="Disable cleanup skipped (not file): {path}",
199
+ )
200
+
201
+ def _cleanup_global_mcp(
202
+ self, app_id: AppId, managed_mcp: dict[str, Any]
203
+ ) -> list[Action]:
204
+ scope = app_scope(app_id, "mcp")
205
+ raw = managed_mcp.get(scope, [])
206
+ managed = (
207
+ {item for item in raw if isinstance(item, str)}
208
+ if isinstance(raw, list)
209
+ else set()
210
+ )
211
+ if not managed:
212
+ return []
213
+ try:
214
+ service = create_registered_app_service(app_id)
215
+ except (KeyError, ValueError):
216
+ return []
217
+ if not service.repository.config_path.exists():
218
+ return []
219
+ # Empty desired + our previously-managed names → prune only our servers,
220
+ # keep the user's, and clear managed_mcp state (mcp_managed becomes []).
221
+ return [service.build_action({}, previously_managed=managed)]
222
+
223
+ def list_status_rows(self) -> list[AppStatusRow]:
224
+ apps = self.load_apps()
225
+ rows: list[AppStatusRow] = []
226
+ for app_name in self.available_apps():
227
+ enabled = apps.get(app_name, False)
228
+ detail = "enabled by user" if enabled else "disabled by default"
229
+ rows.append(
230
+ AppStatusRow(
231
+ name=app_name,
232
+ status=AppSyncStatus.ENABLED if enabled else AppSyncStatus.DISABLED,
233
+ detail=detail,
234
+ )
235
+ )
236
+ return rows
237
+
238
+ def enabled_apps(self) -> list[str]:
239
+ apps = self.load_apps()
240
+ return [name for name in self.available_apps() if apps.get(name, False)]
241
+
242
+ def plan_for_target(self, target: str, *, apply_excludes: bool = False) -> SyncPlan:
243
+ normalized = target.lower()
244
+ if normalized != "all" and not self.is_enabled(normalized):
245
+ return SyncPlan([], [], [f"{normalized} is disabled for sync."])
246
+
247
+ app_services = self._resolve_services_for_target(normalized)
248
+ plan = SyncPlanner(
249
+ core=self.core_repository,
250
+ app_services=app_services,
251
+ include_workspace=True,
252
+ include_git_excludes=apply_excludes,
253
+ ).build()
254
+ if normalized == "all":
255
+ if (
256
+ not app_services
257
+ and not plan.actions
258
+ and not plan.errors
259
+ and not plan.skipped
260
+ ):
261
+ return SyncPlan([], [], ["No apps enabled for sync."])
262
+ return plan
263
+ return plan.filter_for_target(normalized)
264
+
265
+ def execute_plan(self, scoped_plan: SyncPlan) -> tuple[int, int, list[str]]:
266
+ persist_state = self._requires_state_persist(scoped_plan)
267
+ return SyncExecutor(core=self.core_repository).execute(
268
+ scoped_plan, persist_state=persist_state
269
+ )
270
+
271
+ def _resolve_services_for_target(self, target: str) -> list[IAppConfigService]:
272
+ enabled = set(self.enabled_apps())
273
+ if target == "all":
274
+ selected = enabled
275
+ else:
276
+ selected = {target} if target in enabled else set()
277
+
278
+ services: list[IAppConfigService] = []
279
+ for app in sorted(selected):
280
+ try:
281
+ services.append(create_registered_app_service(AppId(app)))
282
+ except (KeyError, ValueError):
283
+ continue
284
+ return services
285
+
286
+ @staticmethod
287
+ def _requires_state_persist(scoped_plan: SyncPlan) -> bool:
288
+ return any(
289
+ action.kind.value in ("symlink", "remove_symlink") or action.app is not None
290
+ for action in scoped_plan.actions
291
+ )
292
+
293
+ def _default_apps(self) -> dict[str, bool]:
294
+ return {name: False for name in self.available_apps()}
@@ -53,6 +53,10 @@ class ClaudeConfigService(RegisteredAppConfigService):
53
53
  def mapper(self) -> IAppMCPMapper:
54
54
  return self._mapper
55
55
 
56
+ @property
57
+ def mcp_config_key(self) -> str:
58
+ return "mcpServers"
59
+
56
60
  def validate_config(self, payload: Any) -> None:
57
61
  if not isinstance(payload, dict):
58
62
  raise InvalidConfigSchemaError(
@@ -62,16 +66,6 @@ class ClaudeConfigService(RegisteredAppConfigService):
62
66
  def build_action_payload(self, payload: dict[str, Any]) -> Any:
63
67
  return payload
64
68
 
65
- def set_mcp_payload(
66
- self, merged: dict[str, Any], desired_mcp: dict[str, Any]
67
- ) -> None:
68
- existing_mcp = merged.get("mcpServers")
69
- preserved = deepcopy(existing_mcp) if isinstance(existing_mcp, dict) else {}
70
- for name, config in desired_mcp.items():
71
- preserved[name] = deepcopy(config)
72
- if preserved:
73
- merged["mcpServers"] = preserved
74
-
75
69
  def derive_status(
76
70
  self, existing: dict[str, Any], merged: dict[str, Any]
77
71
  ) -> ActionStatus:
@@ -3,9 +3,9 @@ from pathlib import Path
3
3
  from typing import Any
4
4
 
5
5
  try:
6
- import tomllib
6
+ import tomllib # type: ignore[import-not-found]
7
7
  except ModuleNotFoundError: # pragma: no cover
8
- import tomli as tomllib # type: ignore
8
+ import tomli as tomllib
9
9
 
10
10
  import tomlkit
11
11
 
@@ -7,7 +7,7 @@ from jsonschema import Draft7Validator
7
7
  from code_agnostic.agents.codex import normalize_codex_agent_filename
8
8
  from code_agnostic.agents.compilers import CodexAgentCompiler
9
9
  from code_agnostic.agents.parser import parse_agent
10
- from code_agnostic.apps.app_id import AppId, app_label
10
+ from code_agnostic.apps.app_id import AppId, app_label, app_scope
11
11
  from code_agnostic.apps.common.framework import (
12
12
  RegisteredAppConfigService,
13
13
  format_schema_error,
@@ -84,6 +84,10 @@ class CodexConfigService(RegisteredAppConfigService):
84
84
  def mapper(self) -> IAppMCPMapper:
85
85
  return self._mapper
86
86
 
87
+ @property
88
+ def mcp_config_key(self) -> str:
89
+ return "mcp_servers"
90
+
87
91
  def validate_config(self, payload: Any) -> None:
88
92
  error = next(iter(self._validator.iter_errors(payload)), None)
89
93
  if error is not None:
@@ -94,18 +98,6 @@ class CodexConfigService(RegisteredAppConfigService):
94
98
  def build_action_payload(self, payload: dict[str, Any]) -> Any:
95
99
  return self.repository.serialize_config(payload)
96
100
 
97
- def set_mcp_payload(
98
- self, merged: dict[str, Any], desired_mcp: dict[str, Any]
99
- ) -> None:
100
- existing_mcp = merged.get("mcp_servers")
101
- preserved = dict(existing_mcp) if isinstance(existing_mcp, dict) else {}
102
- for name, config in desired_mcp.items():
103
- preserved[name] = deepcopy(config)
104
- if preserved:
105
- merged["mcp_servers"] = preserved
106
- else:
107
- merged.pop("mcp_servers", None)
108
-
109
101
  def derive_status(
110
102
  self, existing: dict[str, Any], merged: dict[str, Any]
111
103
  ) -> ActionStatus:
@@ -125,6 +117,9 @@ class CodexConfigService(RegisteredAppConfigService):
125
117
  self,
126
118
  common_servers: dict[str, MCPServerDTO],
127
119
  agent_sources: list[Path] | None = None,
120
+ previously_managed: set[str] | None = None,
121
+ *,
122
+ replace_mcp: bool = False,
128
123
  ) -> Action:
129
124
  existing = self._codex_repo.load_config()
130
125
  if existing or self._codex_repo.config_path.exists():
@@ -146,7 +141,9 @@ class CodexConfigService(RegisteredAppConfigService):
146
141
  merged[key] = merge_dict_overlay(current, value)
147
142
  continue
148
143
  merged[key] = deepcopy(value)
149
- self.set_mcp_payload(merged, desired_mcp)
144
+ self.set_mcp_payload(
145
+ merged, desired_mcp, previously_managed, replace=replace_mcp
146
+ )
150
147
  if agent_sources:
151
148
  merged["agents"] = self._merge_agents_payload(
152
149
  merged.get("agents"),
@@ -154,7 +151,7 @@ class CodexConfigService(RegisteredAppConfigService):
154
151
  )
155
152
  self.validate_config(merged)
156
153
 
157
- return Action(
154
+ action = Action(
158
155
  kind=self.action_kind,
159
156
  path=self.repository.config_path,
160
157
  status=self.derive_status(existing, merged),
@@ -162,6 +159,10 @@ class CodexConfigService(RegisteredAppConfigService):
162
159
  payload=self.build_action_payload(merged),
163
160
  app=self.app_id.value,
164
161
  )
162
+ if not replace_mcp:
163
+ action.scope = app_scope(self.app_id, "mcp")
164
+ action.mcp_managed = sorted(desired_mcp)
165
+ return action
165
166
 
166
167
  def _merge_agents_payload(
167
168
  self, existing: Any, overlay: dict[str, Any]
@@ -20,12 +20,12 @@ class AppServiceRegistryMeta(ABCMeta):
20
20
  bases: tuple[type, ...],
21
21
  namespace: dict[str, Any],
22
22
  **kwargs: Any,
23
- ):
23
+ ) -> "AppServiceRegistryMeta":
24
24
  cls = super().__new__(mcls, name, bases, namespace, **kwargs)
25
25
  app_id = getattr(cls, "APP_ID", None)
26
26
  is_abstract = bool(getattr(cls, "__abstractmethods__", False))
27
27
  if app_id is not None and not is_abstract:
28
- mcls._registry[app_id] = cast(type["RegisteredAppConfigService"], cls) # type: ignore[assignment]
28
+ mcls._registry[app_id] = cast(type["RegisteredAppConfigService"], cls)
29
29
  return cls
30
30
 
31
31
 
@@ -11,6 +11,7 @@ from code_agnostic.apps.common.interfaces.mapper import IAppMCPMapper
11
11
  from code_agnostic.apps.common.interfaces.repositories import IAppConfigRepository
12
12
  from code_agnostic.apps.common.interfaces.repositories import ISourceRepository
13
13
  from code_agnostic.apps.common.models import MCPServerDTO
14
+ from code_agnostic.apps.common.utils import apply_mcp_servers
14
15
  from code_agnostic.apps.common.symlink_planning import (
15
16
  load_state_links,
16
17
  load_state_paths,
@@ -52,6 +53,12 @@ class IAppConfigService(ABC):
52
53
  def mapper(self) -> IAppMCPMapper:
53
54
  raise NotImplementedError
54
55
 
56
+ @property
57
+ @abstractmethod
58
+ def mcp_config_key(self) -> str:
59
+ """Native config key that holds the MCP server map for this editor."""
60
+ raise NotImplementedError
61
+
55
62
  @abstractmethod
56
63
  def validate_config(self, payload: Any) -> None:
57
64
  raise NotImplementedError
@@ -60,11 +67,21 @@ class IAppConfigService(ABC):
60
67
  def build_action_payload(self, payload: dict[str, Any]) -> Any:
61
68
  raise NotImplementedError
62
69
 
63
- @abstractmethod
64
70
  def set_mcp_payload(
65
- self, merged: dict[str, Any], desired_mcp: dict[str, Any]
71
+ self,
72
+ merged: dict[str, Any],
73
+ desired_mcp: dict[str, Any],
74
+ previously_managed: set[str] | None = None,
75
+ *,
76
+ replace: bool = False,
66
77
  ) -> None:
67
- raise NotImplementedError
78
+ result = apply_mcp_servers(
79
+ merged.get(self.mcp_config_key),
80
+ desired_mcp,
81
+ previously_managed,
82
+ replace=replace,
83
+ )
84
+ merged[self.mcp_config_key] = result
68
85
 
69
86
  @abstractmethod
70
87
  def derive_status(
@@ -236,6 +253,9 @@ class IAppConfigService(ABC):
236
253
  self,
237
254
  common_servers: dict[str, MCPServerDTO],
238
255
  agent_sources: list[Path] | None = None,
256
+ previously_managed: set[str] | None = None,
257
+ *,
258
+ replace_mcp: bool = False,
239
259
  ) -> Action:
240
260
  existing = self.repository.load_config()
241
261
  if existing or self.repository.config_path.exists():
@@ -243,10 +263,12 @@ class IAppConfigService(ABC):
243
263
 
244
264
  desired_mcp = self.mapper.from_common(common_servers)
245
265
  merged = dict(existing)
246
- self.set_mcp_payload(merged, desired_mcp)
266
+ self.set_mcp_payload(
267
+ merged, desired_mcp, previously_managed, replace=replace_mcp
268
+ )
247
269
  self.validate_config(merged)
248
270
 
249
- return Action(
271
+ action = Action(
250
272
  kind=self.action_kind,
251
273
  path=self.repository.config_path,
252
274
  status=self.derive_status(existing, merged),
@@ -254,6 +276,12 @@ class IAppConfigService(ABC):
254
276
  payload=self.build_action_payload(merged),
255
277
  app=self.app_id.value,
256
278
  )
279
+ if not replace_mcp:
280
+ # User-shared global config: track which servers we own so a later
281
+ # source removal can be pruned without touching the user's servers.
282
+ action.scope = app_scope(self.app_id, "mcp")
283
+ action.mcp_managed = sorted(desired_mcp)
284
+ return action
257
285
 
258
286
  def build_plan(
259
287
  self,
@@ -263,8 +291,12 @@ class IAppConfigService(ABC):
263
291
  state = source_repository.load_state()
264
292
  managed_links_group = self._normalize_managed_group(state.get("managed_links"))
265
293
  managed_paths_group = self._normalize_managed_group(state.get("managed_paths"))
294
+ managed_mcp_group = self._normalize_managed_group(state.get("managed_mcp"))
266
295
  skill_scope = app_scope(self.app_id, "skills")
267
296
  agent_scope = app_scope(self.app_id, "agents")
297
+ previously_managed_mcp = self._load_state_names(
298
+ managed_mcp_group, app_scope(self.app_id, "mcp")
299
+ )
268
300
 
269
301
  skill_actions, skill_skipped = self._build_compiled_group(
270
302
  sources=source_repository.list_skill_sources(),
@@ -292,6 +324,7 @@ class IAppConfigService(ABC):
292
324
  self.build_action(
293
325
  common_servers,
294
326
  agent_sources=source_repository.list_agent_sources(),
327
+ previously_managed=previously_managed_mcp,
295
328
  ),
296
329
  *skill_actions,
297
330
  *agent_actions,
@@ -299,3 +332,10 @@ class IAppConfigService(ABC):
299
332
  errors=[],
300
333
  skipped=[*skill_skipped, *agent_skipped],
301
334
  )
335
+
336
+ @staticmethod
337
+ def _load_state_names(group: dict[str, Any], scope: str) -> set[str]:
338
+ raw = group.get(scope, [])
339
+ if not isinstance(raw, list):
340
+ return set()
341
+ return {item for item in raw if isinstance(item, str)}
@@ -51,6 +51,6 @@ class JsonSchemaRepository(ISchemaRepository):
51
51
  now = time.time()
52
52
  if cached and now - cached[0] < self.ttl_seconds:
53
53
  return cached[1]
54
- schema = json.loads(path.read_text(encoding="utf-8"))
54
+ schema: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
55
55
  _SCHEMA_CACHE[key] = (now, schema)
56
56
  return schema
@@ -1,3 +1,5 @@
1
+ from collections.abc import Iterable
2
+ from copy import deepcopy
1
3
  from typing import Any
2
4
 
3
5
  from code_agnostic.apps.app_id import AppId, app_ids_by_capability
@@ -30,6 +32,40 @@ def _split_app_targeted_key(key: str) -> tuple[str, str, str] | None:
30
32
  return marker, app_name, server_name
31
33
 
32
34
 
35
+ def apply_mcp_servers(
36
+ existing: Any,
37
+ desired: dict[str, Any],
38
+ previously_managed: Iterable[str] | None = None,
39
+ *,
40
+ replace: bool = False,
41
+ ) -> dict[str, Any]:
42
+ """Merge desired MCP servers into an existing native MCP map.
43
+
44
+ Two ownership models:
45
+
46
+ - ``replace=True`` — the target file is fully owned/generated by this tool
47
+ (workspace/project outputs). The result is exactly ``desired``; anything
48
+ else that was there is dropped.
49
+ - ``replace=False`` (default) — the target is a user-shared config
50
+ (``~/.codex``, ``~/.claude.json``, ``~/.cursor/mcp.json`` …). Servers the
51
+ user added by hand are preserved; servers we previously wrote
52
+ (``previously_managed``) that are no longer desired are pruned; desired
53
+ servers are upserted. This is ownership-aware cleanup: our orphans go, the
54
+ user's stay.
55
+ """
56
+
57
+ if replace:
58
+ return {name: deepcopy(config) for name, config in desired.items()}
59
+
60
+ result: dict[str, Any] = deepcopy(existing) if isinstance(existing, dict) else {}
61
+ for name in previously_managed or ():
62
+ if name not in desired:
63
+ result.pop(name, None)
64
+ for name, config in desired.items():
65
+ result[name] = deepcopy(config)
66
+ return result
67
+
68
+
33
69
  def mcp_servers_for_app(
34
70
  mcp_servers: dict[str, Any], app_id: AppId | str
35
71
  ) -> dict[str, Any]:
@@ -51,6 +51,10 @@ class CopilotConfigService(RegisteredAppConfigService):
51
51
  def mapper(self) -> IAppMCPMapper:
52
52
  return self._mapper
53
53
 
54
+ @property
55
+ def mcp_config_key(self) -> str:
56
+ return "mcpServers"
57
+
54
58
  def validate_config(self, payload: Any) -> None:
55
59
  if not isinstance(payload, dict):
56
60
  raise InvalidConfigSchemaError(
@@ -65,11 +69,6 @@ class CopilotConfigService(RegisteredAppConfigService):
65
69
  def build_action_payload(self, payload: dict[str, Any]) -> Any:
66
70
  return payload
67
71
 
68
- def set_mcp_payload(
69
- self, merged: dict[str, Any], desired_mcp: dict[str, Any]
70
- ) -> None:
71
- merged["mcpServers"] = desired_mcp
72
-
73
72
  def derive_status(
74
73
  self, existing: dict[str, Any], merged: dict[str, Any]
75
74
  ) -> ActionStatus: