code-agnostic 0.3.17__tar.gz → 0.4.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.17 → code_agnostic-0.4.0}/PKG-INFO +4 -1
  2. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/README.md +3 -0
  3. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/__init__.py +1 -1
  4. code_agnostic-0.4.0/code_agnostic/apps/apps_service.py +294 -0
  5. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/apps/claude/service.py +4 -10
  6. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/apps/codex/schema.json +325 -50
  7. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/apps/codex/service.py +16 -15
  8. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/apps/common/interfaces/service.py +45 -5
  9. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/apps/common/utils.py +36 -0
  10. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/apps/copilot/service.py +4 -5
  11. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/apps/cursor/service.py +4 -5
  12. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/apps/opencode/config_repository.py +11 -2
  13. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/apps/opencode/service.py +21 -9
  14. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/cli/commands/apply.py +8 -2
  15. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/cli/commands/apps.py +7 -2
  16. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/cli/commands/plan.py +14 -3
  17. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/core/repository.py +4 -0
  18. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/executor.py +16 -1
  19. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/models.py +1 -0
  20. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/planner.py +33 -23
  21. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/tui/renderers.py +13 -4
  22. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic.egg-info/PKG-INFO +4 -1
  23. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic.egg-info/SOURCES.txt +2 -0
  24. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/pyproject.toml +1 -1
  25. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/tests/test_cli_apply_apps.py +37 -3
  26. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/tests/test_cli_apply_codex.py +4 -2
  27. code_agnostic-0.4.0/tests/test_cli_apply_mcp_cleanup.py +102 -0
  28. code_agnostic-0.4.0/tests/test_cli_disable_cleanup.py +69 -0
  29. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/tests/test_cli_plan.py +12 -0
  30. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/tests/test_common_repository.py +2 -0
  31. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/tests/test_project_config_sync.py +7 -1
  32. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/tests/test_workspace_config_sync.py +5 -1
  33. code_agnostic-0.3.17/code_agnostic/apps/apps_service.py +0 -138
  34. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/LICENSE +0 -0
  35. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/__main__.py +0 -0
  36. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/agents/__init__.py +0 -0
  37. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/agents/claude.py +0 -0
  38. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/agents/codex.py +0 -0
  39. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/agents/compilers.py +0 -0
  40. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/agents/copilot.py +0 -0
  41. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/agents/models.py +0 -0
  42. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/agents/opencode.py +0 -0
  43. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/agents/parser.py +0 -0
  44. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/apps/__init__.py +0 -0
  45. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/apps/app_id.py +0 -0
  46. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/apps/claude/__init__.py +0 -0
  47. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/apps/claude/config_repository.py +0 -0
  48. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/apps/claude/mapper.py +0 -0
  49. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/apps/codex/__init__.py +0 -0
  50. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/apps/codex/config_repository.py +0 -0
  51. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/apps/codex/mapper.py +0 -0
  52. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/apps/codex/schema_repository.py +0 -0
  53. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/apps/common/__init__.py +0 -0
  54. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/apps/common/compiled_planning.py +0 -0
  55. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/apps/common/framework.py +0 -0
  56. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/apps/common/interfaces/__init__.py +0 -0
  57. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/apps/common/interfaces/mapper.py +0 -0
  58. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/apps/common/interfaces/repositories.py +0 -0
  59. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/apps/common/loader.py +0 -0
  60. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/apps/common/models.py +0 -0
  61. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/apps/common/schema.py +0 -0
  62. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/apps/common/symlink_planning.py +0 -0
  63. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/apps/copilot/__init__.py +0 -0
  64. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/apps/copilot/config_repository.py +0 -0
  65. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/apps/copilot/mapper.py +0 -0
  66. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/apps/cursor/__init__.py +0 -0
  67. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/apps/cursor/config_repository.py +0 -0
  68. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/apps/cursor/mapper.py +0 -0
  69. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/apps/cursor/schema.json +0 -0
  70. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/apps/cursor/schema_repository.py +0 -0
  71. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/apps/opencode/__init__.py +0 -0
  72. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/apps/opencode/mapper.py +0 -0
  73. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/apps/opencode/schema.json +0 -0
  74. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/apps/opencode/schema_repository.py +0 -0
  75. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/cli/__init__.py +0 -0
  76. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/cli/aliases.py +0 -0
  77. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/cli/commands/__init__.py +0 -0
  78. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/cli/commands/agents.py +0 -0
  79. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/cli/commands/explain_lossiness.py +0 -0
  80. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/cli/commands/import_.py +0 -0
  81. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/cli/commands/mcp.py +0 -0
  82. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/cli/commands/projects.py +0 -0
  83. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/cli/commands/restore.py +0 -0
  84. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/cli/commands/rules.py +0 -0
  85. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/cli/commands/skills.py +0 -0
  86. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/cli/commands/status.py +0 -0
  87. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/cli/commands/validate.py +0 -0
  88. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/cli/commands/workspaces.py +0 -0
  89. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/cli/helpers.py +0 -0
  90. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/cli/options.py +0 -0
  91. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/constants.py +0 -0
  92. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/core/__init__.py +0 -0
  93. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/core/project_repository.py +0 -0
  94. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/core/workspace_repository.py +0 -0
  95. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/errors.py +0 -0
  96. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/generated_artifacts.py +0 -0
  97. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/git_exclude_service.py +0 -0
  98. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/imports/__init__.py +0 -0
  99. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/imports/adapters.py +0 -0
  100. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/imports/filesystem.py +0 -0
  101. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/imports/models.py +0 -0
  102. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/imports/service.py +0 -0
  103. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/lossiness.py +0 -0
  104. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/mcp_service.py +0 -0
  105. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/project_artifacts.py +0 -0
  106. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/rules/__init__.py +0 -0
  107. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/rules/compilers.py +0 -0
  108. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/rules/models.py +0 -0
  109. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/rules/parser.py +0 -0
  110. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/rules/repository.py +0 -0
  111. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/skills/__init__.py +0 -0
  112. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/skills/compilers.py +0 -0
  113. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/skills/install_sources.py +0 -0
  114. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/skills/models.py +0 -0
  115. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/skills/parser.py +0 -0
  116. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/spec/__init__.py +0 -0
  117. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/spec/loaders.py +0 -0
  118. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/spec/schemas/agent.v1.schema.json +0 -0
  119. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/spec/schemas/mcp.base.schema.json +0 -0
  120. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/spec/schemas/mcp.v1.schema.json +0 -0
  121. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/spec/schemas/rule.v1.schema.json +0 -0
  122. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/spec/schemas/skill.v1.schema.json +0 -0
  123. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/status.py +0 -0
  124. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/tui/__init__.py +0 -0
  125. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/tui/enums.py +0 -0
  126. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/tui/import_selector.py +0 -0
  127. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/tui/sections.py +0 -0
  128. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/tui/tables.py +0 -0
  129. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/utils.py +0 -0
  130. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/validation.py +0 -0
  131. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/workspace_artifacts.py +0 -0
  132. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic/workspaces.py +0 -0
  133. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic.egg-info/dependency_links.txt +0 -0
  134. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic.egg-info/entry_points.txt +0 -0
  135. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic.egg-info/requires.txt +0 -0
  136. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/code_agnostic.egg-info/top_level.txt +0 -0
  137. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/setup.cfg +0 -0
  138. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/tests/test_cli_agents.py +0 -0
  139. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/tests/test_cli_aliases.py +0 -0
  140. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/tests/test_cli_apply_cursor.py +0 -0
  141. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/tests/test_cli_apply_target.py +0 -0
  142. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/tests/test_cli_apps.py +0 -0
  143. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/tests/test_cli_explain_lossiness.py +0 -0
  144. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/tests/test_cli_flags.py +0 -0
  145. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/tests/test_cli_git_exclude.py +0 -0
  146. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/tests/test_cli_import.py +0 -0
  147. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/tests/test_cli_import_interactive.py +0 -0
  148. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/tests/test_cli_mcp.py +0 -0
  149. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/tests/test_cli_module_organization.py +0 -0
  150. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/tests/test_cli_projects.py +0 -0
  151. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/tests/test_cli_restore.py +0 -0
  152. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/tests/test_cli_rules.py +0 -0
  153. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/tests/test_cli_skills.py +0 -0
  154. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/tests/test_cli_status.py +0 -0
  155. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/tests/test_cli_validate.py +0 -0
  156. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/tests/test_cli_workspace_resolution.py +0 -0
  157. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/tests/test_cli_workspaces.py +0 -0
  158. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/tests/test_common_mcp_to_dto.py +0 -0
  159. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/tests/test_compiled_planning.py +0 -0
  160. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/tests/test_dto_to_common_mcp.py +0 -0
  161. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/tests/test_git_exclude_service.py +0 -0
  162. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/tests/test_mcp_service.py +0 -0
  163. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/tests/test_planner_executor.py +0 -0
  164. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/tests/test_planner_rules.py +0 -0
  165. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/tests/test_skill_install_sources.py +0 -0
  166. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/tests/test_symlink_planning.py +0 -0
  167. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/tests/test_sync_plan_model.py +0 -0
  168. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/tests/test_transactional_executor.py +0 -0
  169. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/tests/test_utils.py +0 -0
  170. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/tests/test_version.py +0 -0
  171. {code_agnostic-0.3.17 → code_agnostic-0.4.0}/tests/test_workspace_repo_status.py +0 -0
  172. {code_agnostic-0.3.17 → code_agnostic-0.4.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.17
3
+ Version: 0.4.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
@@ -305,7 +305,10 @@ code-agnostic workspaces list
305
305
 
306
306
  Prevent synced paths from showing up in `git status`. Managed per-workspace with customizable patterns.
307
307
 
308
+ Writing `.git/info/exclude` is always explicit — `apply` does **not** touch it by default. Opt in per run with `apply --apply-excludes`, or use the standalone command:
309
+
308
310
  ```bash
311
+ code-agnostic apply --apply-excludes # apply + write excludes
309
312
  code-agnostic workspaces git-exclude # all workspaces
310
313
  code-agnostic workspaces git-exclude -w myproject # one workspace
311
314
  code-agnostic workspaces exclude-add --pattern "*.generated" -w myproject
@@ -280,7 +280,10 @@ code-agnostic workspaces list
280
280
 
281
281
  Prevent synced paths from showing up in `git status`. Managed per-workspace with customizable patterns.
282
282
 
283
+ Writing `.git/info/exclude` is always explicit — `apply` does **not** touch it by default. Opt in per run with `apply --apply-excludes`, or use the standalone command:
284
+
283
285
  ```bash
286
+ code-agnostic apply --apply-excludes # apply + write excludes
284
287
  code-agnostic workspaces git-exclude # all workspaces
285
288
  code-agnostic workspaces git-exclude -w myproject # one workspace
286
289
  code-agnostic workspaces exclude-add --pattern "*.generated" -w myproject
@@ -1,3 +1,3 @@
1
1
  __all__ = ["__version__"]
2
2
 
3
- __version__ = "0.3.17"
3
+ __version__ = "0.4.0"
@@ -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.interfaces.repositories import ISourceRepository
11
+ from code_agnostic.apps.common.symlink_planning import (
12
+ load_state_links,
13
+ load_state_paths,
14
+ plan_stale_files_group,
15
+ plan_stale_group,
16
+ )
17
+ from code_agnostic.core.project_repository import ProjectConfigRepository
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: ISourceRepository) -> 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: