code-agnostic 0.6.0__tar.gz → 0.8.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.6.0 → code_agnostic-0.8.0}/PKG-INFO +1 -1
  2. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/__init__.py +1 -1
  3. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/apps_service.py +31 -15
  4. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/claude/service.py +19 -2
  5. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/common/interfaces/repositories.py +3 -1
  6. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/common/interfaces/service.py +3 -7
  7. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/core/repository.py +5 -29
  8. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/executor.py +7 -7
  9. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/models.py +63 -1
  10. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/planner.py +16 -16
  11. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/status.py +2 -2
  12. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic.egg-info/PKG-INFO +1 -1
  13. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/pyproject.toml +1 -1
  14. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_disable_cleanup.py +48 -0
  15. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_common_repository.py +4 -17
  16. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_planner_executor.py +2 -4
  17. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_sync_plan_model.py +44 -0
  18. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_workspace_config_sync.py +86 -9
  19. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/LICENSE +0 -0
  20. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/README.md +0 -0
  21. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/__main__.py +0 -0
  22. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/agents/__init__.py +0 -0
  23. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/agents/claude.py +0 -0
  24. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/agents/codex.py +0 -0
  25. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/agents/compilers.py +0 -0
  26. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/agents/copilot.py +0 -0
  27. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/agents/models.py +0 -0
  28. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/agents/opencode.py +0 -0
  29. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/agents/parser.py +0 -0
  30. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/__init__.py +0 -0
  31. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/app_id.py +0 -0
  32. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/claude/__init__.py +0 -0
  33. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/claude/config_repository.py +0 -0
  34. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/claude/mapper.py +0 -0
  35. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/codex/__init__.py +0 -0
  36. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/codex/config_repository.py +0 -0
  37. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/codex/mapper.py +0 -0
  38. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/codex/schema.json +0 -0
  39. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/codex/schema_repository.py +0 -0
  40. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/codex/service.py +0 -0
  41. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/common/__init__.py +0 -0
  42. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/common/compiled_planning.py +0 -0
  43. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/common/framework.py +0 -0
  44. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/common/interfaces/__init__.py +0 -0
  45. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/common/interfaces/mapper.py +0 -0
  46. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/common/loader.py +0 -0
  47. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/common/models.py +0 -0
  48. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/common/schema.py +0 -0
  49. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/common/symlink_planning.py +0 -0
  50. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/common/utils.py +0 -0
  51. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/copilot/__init__.py +0 -0
  52. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/copilot/config_repository.py +0 -0
  53. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/copilot/mapper.py +0 -0
  54. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/copilot/service.py +0 -0
  55. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/cursor/__init__.py +0 -0
  56. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/cursor/config_repository.py +0 -0
  57. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/cursor/mapper.py +0 -0
  58. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/cursor/schema.json +0 -0
  59. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/cursor/schema_repository.py +0 -0
  60. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/cursor/service.py +0 -0
  61. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/opencode/__init__.py +0 -0
  62. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/opencode/config_repository.py +0 -0
  63. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/opencode/mapper.py +0 -0
  64. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/opencode/schema.json +0 -0
  65. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/opencode/schema_repository.py +0 -0
  66. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/opencode/service.py +0 -0
  67. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/cli/__init__.py +0 -0
  68. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/cli/aliases.py +0 -0
  69. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/cli/commands/__init__.py +0 -0
  70. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/cli/commands/agents.py +0 -0
  71. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/cli/commands/apply.py +0 -0
  72. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/cli/commands/apps.py +0 -0
  73. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/cli/commands/explain_lossiness.py +0 -0
  74. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/cli/commands/import_.py +0 -0
  75. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/cli/commands/mcp.py +0 -0
  76. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/cli/commands/plan.py +0 -0
  77. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/cli/commands/projects.py +0 -0
  78. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/cli/commands/restore.py +0 -0
  79. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/cli/commands/rules.py +0 -0
  80. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/cli/commands/skills.py +0 -0
  81. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/cli/commands/status.py +0 -0
  82. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/cli/commands/validate.py +0 -0
  83. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/cli/commands/workspaces.py +0 -0
  84. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/cli/helpers.py +0 -0
  85. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/cli/options.py +0 -0
  86. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/constants.py +0 -0
  87. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/core/__init__.py +0 -0
  88. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/core/project_repository.py +0 -0
  89. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/core/workspace_repository.py +0 -0
  90. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/errors.py +0 -0
  91. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/generated_artifacts.py +0 -0
  92. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/git_exclude_service.py +0 -0
  93. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/imports/__init__.py +0 -0
  94. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/imports/adapters.py +0 -0
  95. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/imports/filesystem.py +0 -0
  96. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/imports/models.py +0 -0
  97. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/imports/service.py +0 -0
  98. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/lossiness.py +0 -0
  99. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/mcp_service.py +0 -0
  100. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/project_artifacts.py +0 -0
  101. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/rules/__init__.py +0 -0
  102. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/rules/compilers.py +0 -0
  103. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/rules/models.py +0 -0
  104. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/rules/parser.py +0 -0
  105. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/rules/repository.py +0 -0
  106. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/skills/__init__.py +0 -0
  107. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/skills/compilers.py +0 -0
  108. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/skills/install_sources.py +0 -0
  109. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/skills/models.py +0 -0
  110. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/skills/parser.py +0 -0
  111. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/spec/__init__.py +0 -0
  112. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/spec/loaders.py +0 -0
  113. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/spec/schemas/agent.v1.schema.json +0 -0
  114. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/spec/schemas/mcp.base.schema.json +0 -0
  115. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/spec/schemas/mcp.v1.schema.json +0 -0
  116. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/spec/schemas/rule.v1.schema.json +0 -0
  117. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/spec/schemas/skill.v1.schema.json +0 -0
  118. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/tui/__init__.py +0 -0
  119. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/tui/enums.py +0 -0
  120. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/tui/import_selector.py +0 -0
  121. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/tui/renderers.py +0 -0
  122. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/tui/sections.py +0 -0
  123. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/tui/tables.py +0 -0
  124. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/utils.py +0 -0
  125. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/validation.py +0 -0
  126. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/workspace_artifacts.py +0 -0
  127. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/workspaces.py +0 -0
  128. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic.egg-info/SOURCES.txt +0 -0
  129. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic.egg-info/dependency_links.txt +0 -0
  130. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic.egg-info/entry_points.txt +0 -0
  131. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic.egg-info/requires.txt +0 -0
  132. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic.egg-info/top_level.txt +0 -0
  133. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/setup.cfg +0 -0
  134. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_agents.py +0 -0
  135. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_aliases.py +0 -0
  136. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_apply_apps.py +0 -0
  137. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_apply_codex.py +0 -0
  138. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_apply_codex_agent_cleanup.py +0 -0
  139. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_apply_cursor.py +0 -0
  140. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_apply_mcp_cleanup.py +0 -0
  141. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_apply_target.py +0 -0
  142. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_apps.py +0 -0
  143. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_explain_lossiness.py +0 -0
  144. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_flags.py +0 -0
  145. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_git_exclude.py +0 -0
  146. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_import.py +0 -0
  147. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_import_interactive.py +0 -0
  148. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_mcp.py +0 -0
  149. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_module_organization.py +0 -0
  150. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_plan.py +0 -0
  151. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_projects.py +0 -0
  152. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_restore.py +0 -0
  153. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_rules.py +0 -0
  154. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_skills.py +0 -0
  155. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_status.py +0 -0
  156. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_validate.py +0 -0
  157. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_workspace_resolution.py +0 -0
  158. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_workspaces.py +0 -0
  159. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_common_mcp_to_dto.py +0 -0
  160. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_compiled_planning.py +0 -0
  161. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_dto_to_common_mcp.py +0 -0
  162. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_git_exclude_service.py +0 -0
  163. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_mcp_service.py +0 -0
  164. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_planner_rules.py +0 -0
  165. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_project_config_sync.py +0 -0
  166. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_skill_install_sources.py +0 -0
  167. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_symlink_planning.py +0 -0
  168. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_transactional_executor.py +0 -0
  169. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_utils.py +0 -0
  170. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_version.py +0 -0
  171. {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_workspace_repo_status.py +0 -0
  172. {code_agnostic-0.6.0 → code_agnostic-0.8.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.6.0
3
+ Version: 0.8.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.6.0"
3
+ __version__ = "0.8.0"
@@ -6,6 +6,7 @@ from code_agnostic.apps.common.framework import (
6
6
  create_registered_app_service,
7
7
  list_registered_app_services,
8
8
  )
9
+ from code_agnostic.apps.claude.service import ClaudeConfigService
9
10
  from code_agnostic.apps.common.interfaces.service import IAppConfigService
10
11
  from code_agnostic.apps.common.symlink_planning import (
11
12
  load_state_links,
@@ -102,9 +103,9 @@ class AppsService:
102
103
 
103
104
  # --- Global scopes (app:<app>:*) ---
104
105
  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"))
106
+ global_links = global_state.managed_links
107
+ global_paths = global_state.managed_paths
108
+ global_mcp = global_state.managed_mcp
108
109
  global_prefix = f"app:{app_id.value}:"
109
110
  for scope in sorted(s for s in global_links if s.startswith(global_prefix)):
110
111
  actions.extend(self._cleanup_links(global_links, scope, app_id.value))
@@ -113,6 +114,8 @@ class AppsService:
113
114
  self._cleanup_paths(global_paths, scope, app_id.value, skipped)
114
115
  )
115
116
  actions.extend(self._cleanup_global_mcp(app_id, global_mcp))
117
+ if app_id == AppId.CLAUDE:
118
+ actions.extend(self._cleanup_claude_projects(global_mcp))
116
119
 
117
120
  # --- Workspace scopes (ws:<app>:*) ---
118
121
  ws_prefix = f"ws:{app_id.value}:"
@@ -120,8 +123,8 @@ class AppsService:
120
123
  ws_name = workspace["name"]
121
124
  ws_repo = WorkspaceConfigRepository(root=core.workspace_config_dir(ws_name))
122
125
  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"))
126
+ ws_links = ws_state.managed_links
127
+ ws_paths = ws_state.managed_paths
125
128
  for scope in sorted(s for s in ws_links if s.startswith(ws_prefix)):
126
129
  for action in self._cleanup_links(ws_links, scope, "workspace"):
127
130
  action.workspace = ws_name
@@ -141,8 +144,8 @@ class AppsService:
141
144
  root=project_config_dir(core, project_name)
142
145
  )
143
146
  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"))
147
+ project_links = project_state.managed_links
148
+ project_paths = project_state.managed_paths
146
149
  for scope in sorted(
147
150
  s for s in project_links if s.startswith(project_prefix)
148
151
  ):
@@ -160,14 +163,6 @@ class AppsService:
160
163
 
161
164
  return SyncPlan(actions=actions, errors=[], skipped=skipped)
162
165
 
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
166
  @staticmethod
172
167
  def _cleanup_links(group: dict[str, Any], scope: str, app: str) -> list[Action]:
173
168
  return plan_stale_group(
@@ -224,6 +219,27 @@ class AppsService:
224
219
  )
225
220
  ]
226
221
 
222
+ def _cleanup_claude_projects(self, managed_mcp: dict[str, Any]) -> list[Action]:
223
+ managed = self._names_for_scope(
224
+ managed_mcp, app_scope(AppId.CLAUDE, "projects")
225
+ )
226
+ if not managed:
227
+ return []
228
+ try:
229
+ service = create_registered_app_service(AppId.CLAUDE)
230
+ except (KeyError, ValueError):
231
+ return []
232
+ if not isinstance(service, ClaudeConfigService):
233
+ return []
234
+ if not service.repository.config_path.exists():
235
+ return []
236
+ # Empty desired + our previously-managed project paths → prune only the
237
+ # `mcpServers` sub-keys we wrote, keep the rest of each project entry, and
238
+ # clear the ownership state (managed_entries becomes empty).
239
+ return [
240
+ service.build_project_mcp_action({}, previously_managed_projects=managed)
241
+ ]
242
+
227
243
  @staticmethod
228
244
  def _names_for_scope(group: dict[str, Any], scope: str) -> set[str]:
229
245
  raw = group.get(scope, [])
@@ -5,7 +5,7 @@ from typing import Any
5
5
  from code_agnostic.agents.claude import claude_agent_target_path
6
6
  from code_agnostic.agents.compilers import ClaudeAgentCompiler
7
7
  from code_agnostic.agents.parser import parse_agent
8
- from code_agnostic.apps.app_id import AppId, app_label
8
+ from code_agnostic.apps.app_id import AppId, app_label, app_scope
9
9
  from code_agnostic.apps.claude.config_repository import ClaudeConfigRepository
10
10
  from code_agnostic.apps.claude.mapper import ClaudeMCPMapper
11
11
  from code_agnostic.apps.common.framework import RegisteredAppConfigService
@@ -72,6 +72,7 @@ class ClaudeConfigService(RegisteredAppConfigService):
72
72
  self,
73
73
  project_servers: dict[Path, dict[str, MCPServerDTO]],
74
74
  base_payload: dict[str, Any] | None = None,
75
+ previously_managed_projects: set[str] | None = None,
75
76
  ) -> Action:
76
77
  existing = self._repository.load_config()
77
78
  if existing or self.repository.config_path.exists():
@@ -86,8 +87,10 @@ class ClaudeConfigService(RegisteredAppConfigService):
86
87
  else:
87
88
  projects = deepcopy(projects)
88
89
 
90
+ desired_keys: set[str] = set()
89
91
  for project_path, servers in project_servers.items():
90
92
  key = str(project_path.resolve())
93
+ desired_keys.add(key)
91
94
  project = projects.get(key)
92
95
  if not isinstance(project, dict):
93
96
  project = {}
@@ -96,10 +99,18 @@ class ClaudeConfigService(RegisteredAppConfigService):
96
99
  project["mcpServers"] = self.mapper.from_common(servers)
97
100
  projects[key] = project
98
101
 
102
+ # Prune the `mcpServers` sub-key from project entries we previously wrote
103
+ # but no longer sync (e.g. a repo removed from a workspace). Leave the rest
104
+ # of the project entry (history etc.) and non-managed projects untouched.
105
+ for stale_key in (previously_managed_projects or set()) - desired_keys:
106
+ stale_project = projects.get(stale_key)
107
+ if isinstance(stale_project, dict):
108
+ stale_project.pop("mcpServers", None)
109
+
99
110
  merged["projects"] = projects
100
111
  self.validate_config(merged)
101
112
 
102
- return Action(
113
+ action = Action(
103
114
  kind=self.action_kind,
104
115
  path=self.repository.config_path,
105
116
  status=self.derive_status(existing, merged),
@@ -107,6 +118,12 @@ class ClaudeConfigService(RegisteredAppConfigService):
107
118
  payload=merged,
108
119
  app=self.app_id.value,
109
120
  )
121
+ # Track which project paths we own so a later source removal prunes only
122
+ # our `mcpServers` sub-keys without touching the user's project entries.
123
+ projects_scope = app_scope(self.app_id, "projects")
124
+ action.scope = projects_scope
125
+ action.managed_entries = {projects_scope: sorted(desired_keys)}
126
+ return action
110
127
 
111
128
  def plan_skill_actions(
112
129
  self,
@@ -2,6 +2,8 @@ from abc import ABC, abstractmethod
2
2
  from pathlib import Path
3
3
  from typing import Any
4
4
 
5
+ from code_agnostic.models import SyncState
6
+
5
7
 
6
8
  class ISchemaRepository(ABC):
7
9
  @abstractmethod
@@ -59,7 +61,7 @@ class IConfigRepository(ABC):
59
61
  raise NotImplementedError
60
62
 
61
63
  @abstractmethod
62
- def load_state(self) -> dict[str, Any]:
64
+ def load_state(self) -> SyncState:
63
65
  raise NotImplementedError
64
66
 
65
67
  @abstractmethod
@@ -135,10 +135,6 @@ class IAppConfigService(ABC):
135
135
  def compiled_resource_ownership_policy(self) -> OwnershipPolicy:
136
136
  return OwnershipPolicy.OWNED_ONLY
137
137
 
138
- @staticmethod
139
- def _normalize_managed_group(value: Any) -> dict[str, Any]:
140
- return value if isinstance(value, dict) else {}
141
-
142
138
  def _plan_compiled_text_actions(
143
139
  self,
144
140
  *,
@@ -308,9 +304,9 @@ class IAppConfigService(ABC):
308
304
  source_repository: ISourceRepository,
309
305
  ) -> SyncPlan:
310
306
  state = source_repository.load_state()
311
- managed_links_group = self._normalize_managed_group(state.get("managed_links"))
312
- managed_paths_group = self._normalize_managed_group(state.get("managed_paths"))
313
- managed_mcp_group = self._normalize_managed_group(state.get("managed_mcp"))
307
+ managed_links_group = state.managed_links
308
+ managed_paths_group = state.managed_paths
309
+ managed_mcp_group = state.managed_mcp
314
310
  skill_scope = app_scope(self.app_id, "skills")
315
311
  agent_scope = app_scope(self.app_id, "agents")
316
312
  previously_managed_mcp = self._load_state_names(
@@ -10,6 +10,7 @@ from code_agnostic.errors import (
10
10
  InvalidJsonFormatError,
11
11
  MissingConfigFileError,
12
12
  )
13
+ from code_agnostic.models import SyncState
13
14
  from code_agnostic.spec.loaders import (
14
15
  load_mcp_base as load_mcp_bundle,
15
16
  validate_schema_payload,
@@ -110,36 +111,11 @@ class BaseSourceRepository(ISourceRepository):
110
111
  result.append(child)
111
112
  return result
112
113
 
113
- def load_state(self) -> dict[str, Any]:
114
+ def load_state(self) -> SyncState:
114
115
  payload, error = read_json_safe(self.state_json)
115
- if error is not None or not isinstance(payload, dict):
116
- return {
117
- "managed_skill_links": [],
118
- "managed_agent_links": [],
119
- "managed_workspace_links": [],
120
- "managed_links": {},
121
- "managed_paths": {},
122
- "managed_mcp": {},
123
- }
124
- payload.setdefault("managed_skill_links", [])
125
- payload.setdefault("managed_agent_links", [])
126
- payload.setdefault("managed_workspace_links", [])
127
- payload.setdefault("managed_links", {})
128
- payload.setdefault("managed_paths", {})
129
- payload.setdefault("managed_mcp", {})
130
- if not isinstance(payload["managed_skill_links"], list):
131
- payload["managed_skill_links"] = []
132
- if not isinstance(payload["managed_agent_links"], list):
133
- payload["managed_agent_links"] = []
134
- if not isinstance(payload["managed_workspace_links"], list):
135
- payload["managed_workspace_links"] = []
136
- if not isinstance(payload["managed_links"], dict):
137
- payload["managed_links"] = {}
138
- if not isinstance(payload["managed_paths"], dict):
139
- payload["managed_paths"] = {}
140
- if not isinstance(payload["managed_mcp"], dict):
141
- payload["managed_mcp"] = {}
142
- return payload
116
+ if error is not None:
117
+ return SyncState()
118
+ return SyncState.from_payload(payload)
143
119
 
144
120
  def save_state(self, data: dict[str, Any]) -> None:
145
121
  write_json(self.state_json, data)
@@ -830,17 +830,17 @@ class SyncExecutor:
830
830
  global_state = {
831
831
  "updated_at": updated_at,
832
832
  "managed_links": self._merge_managed_links(
833
- existing=existing_global_state.get("managed_links"),
833
+ existing=existing_global_state.managed_links,
834
834
  touched_scopes=global_touched_scopes,
835
835
  current_links=global_links,
836
836
  ),
837
837
  "managed_paths": self._merge_managed_links(
838
- existing=existing_global_state.get("managed_paths"),
838
+ existing=existing_global_state.managed_paths,
839
839
  touched_scopes=global_touched_scopes,
840
840
  current_links=global_paths,
841
841
  ),
842
842
  "managed_mcp": self._merge_managed_links(
843
- existing=existing_global_state.get("managed_mcp"),
843
+ existing=existing_global_state.managed_mcp,
844
844
  touched_scopes=global_mcp_touched,
845
845
  current_links=global_mcp,
846
846
  ),
@@ -863,12 +863,12 @@ class SyncExecutor:
863
863
  ws_state = {
864
864
  "updated_at": updated_at,
865
865
  "managed_links": self._merge_managed_links(
866
- existing=existing_workspace_state.get("managed_links"),
866
+ existing=existing_workspace_state.managed_links,
867
867
  touched_scopes=workspace_touched_scopes[ws_name],
868
868
  current_links=workspace_links.get(ws_name, {}),
869
869
  ),
870
870
  "managed_paths": self._merge_managed_links(
871
- existing=existing_workspace_state.get("managed_paths"),
871
+ existing=existing_workspace_state.managed_paths,
872
872
  touched_scopes=workspace_touched_scopes[ws_name],
873
873
  current_links=workspace_paths.get(ws_name, {}),
874
874
  ),
@@ -893,12 +893,12 @@ class SyncExecutor:
893
893
  project_state = {
894
894
  "updated_at": updated_at,
895
895
  "managed_links": self._merge_managed_links(
896
- existing=existing_project_state.get("managed_links"),
896
+ existing=existing_project_state.managed_links,
897
897
  touched_scopes=project_touched_scopes[project_name],
898
898
  current_links=project_links.get(project_name, {}),
899
899
  ),
900
900
  "managed_paths": self._merge_managed_links(
901
- existing=existing_project_state.get("managed_paths"),
901
+ existing=existing_project_state.managed_paths,
902
902
  touched_scopes=project_touched_scopes[project_name],
903
903
  current_links=project_paths.get(project_name, {}),
904
904
  ),
@@ -1,4 +1,4 @@
1
- from dataclasses import dataclass
1
+ from dataclasses import dataclass, field
2
2
  from enum import Enum
3
3
  from pathlib import Path
4
4
  from typing import Any
@@ -146,6 +146,68 @@ class SyncPlan:
146
146
  )
147
147
 
148
148
 
149
+ @dataclass(frozen=True)
150
+ class SyncState:
151
+ """Typed view of a repository's `.sync-state.json`.
152
+
153
+ All defensive normalization (missing keys, wrong JSON types, stray non-string
154
+ entries) happens once in `from_payload`; consumers read the typed fields
155
+ directly instead of re-guarding with `isinstance` at every call site.
156
+
157
+ `managed_links`/`managed_paths`/`managed_mcp` are `scope -> names` maps (the
158
+ live ownership model). The three `managed_*_links` lists are vestigial — no
159
+ production reader consumes them — but are kept for on-disk round-trips.
160
+ """
161
+
162
+ managed_links: dict[str, list[str]] = field(default_factory=dict)
163
+ managed_paths: dict[str, list[str]] = field(default_factory=dict)
164
+ managed_mcp: dict[str, list[str]] = field(default_factory=dict)
165
+ managed_skill_links: list[str] = field(default_factory=list)
166
+ managed_agent_links: list[str] = field(default_factory=list)
167
+ managed_workspace_links: list[str] = field(default_factory=list)
168
+ updated_at: str | None = None
169
+ skipped: list[str] = field(default_factory=list)
170
+
171
+ @classmethod
172
+ def from_payload(cls, payload: Any) -> "SyncState":
173
+ if not isinstance(payload, dict):
174
+ return cls()
175
+ updated_at = payload.get("updated_at")
176
+ return cls(
177
+ managed_links=cls._coerce_group(payload.get("managed_links")),
178
+ managed_paths=cls._coerce_group(payload.get("managed_paths")),
179
+ managed_mcp=cls._coerce_group(payload.get("managed_mcp")),
180
+ managed_skill_links=cls._coerce_str_list(
181
+ payload.get("managed_skill_links")
182
+ ),
183
+ managed_agent_links=cls._coerce_str_list(
184
+ payload.get("managed_agent_links")
185
+ ),
186
+ managed_workspace_links=cls._coerce_str_list(
187
+ payload.get("managed_workspace_links")
188
+ ),
189
+ updated_at=updated_at if isinstance(updated_at, str) else None,
190
+ skipped=cls._coerce_str_list(payload.get("skipped")),
191
+ )
192
+
193
+ @staticmethod
194
+ def _coerce_group(value: Any) -> dict[str, list[str]]:
195
+ if not isinstance(value, dict):
196
+ return {}
197
+ result: dict[str, list[str]] = {}
198
+ for scope, names in value.items():
199
+ if not isinstance(scope, str) or not isinstance(names, list):
200
+ continue
201
+ result[scope] = [name for name in names if isinstance(name, str)]
202
+ return result
203
+
204
+ @staticmethod
205
+ def _coerce_str_list(value: Any) -> list[str]:
206
+ if not isinstance(value, list):
207
+ return []
208
+ return [item for item in value if isinstance(item, str)]
209
+
210
+
149
211
  @dataclass(frozen=True)
150
212
  class WorkspaceConfig:
151
213
  name: str
@@ -1,6 +1,6 @@
1
1
  from pathlib import Path
2
2
 
3
- from code_agnostic.apps.app_id import AppId, app_metadata
3
+ from code_agnostic.apps.app_id import AppId, app_metadata, app_scope
4
4
  from code_agnostic.apps.common.compiled_planning import (
5
5
  plan_owned_compiled_text_action,
6
6
  )
@@ -283,15 +283,23 @@ class SyncPlanner:
283
283
  if existing_action is not None and isinstance(existing_action.payload, dict)
284
284
  else None
285
285
  )
286
+ state = self.core.load_state()
287
+ previously_managed_projects = set(
288
+ state.managed_mcp.get(app_scope(AppId.CLAUDE, "projects"), [])
289
+ )
286
290
  project_action = claude_service.build_project_mcp_action(
287
291
  self._claude_project_mcp,
288
292
  base_payload=base_payload,
293
+ previously_managed_projects=previously_managed_projects,
289
294
  )
290
295
  if existing_action is not None:
291
296
  # Carry global MCP ownership tracking onto the merged Claude action so
292
- # top-level `mcpServers` orphans still get pruned on later applies.
293
- project_action.scope = existing_action.scope
294
- project_action.managed_entries = existing_action.managed_entries
297
+ # top-level `mcpServers` orphans still get pruned on later applies. The
298
+ # project-path ownership set by build_project_mcp_action is preserved.
299
+ project_action.scope = existing_action.scope or project_action.scope
300
+ merged_entries = dict(existing_action.managed_entries or {})
301
+ merged_entries.update(project_action.managed_entries or {})
302
+ project_action.managed_entries = merged_entries
295
303
  actions = [
296
304
  action
297
305
  for action in plan.actions
@@ -352,12 +360,8 @@ class SyncPlanner:
352
360
  skill_sources = project_source.list_skill_sources()
353
361
 
354
362
  state = project_source.load_state()
355
- managed_links = state.get("managed_links", {})
356
- if not isinstance(managed_links, dict):
357
- managed_links = {}
358
- managed_paths = state.get("managed_paths", {})
359
- if not isinstance(managed_paths, dict):
360
- managed_paths = {}
363
+ managed_links = state.managed_links
364
+ managed_paths = state.managed_paths
361
365
 
362
366
  actions: list[Action] = []
363
367
  skipped: list[str] = []
@@ -471,12 +475,8 @@ class SyncPlanner:
471
475
 
472
476
  repos = self.workspace_service.discover_git_repos(workspace_path)
473
477
  state = ws_source.load_state()
474
- managed_links = state.get("managed_links", {})
475
- if not isinstance(managed_links, dict):
476
- managed_links = {}
477
- managed_paths = state.get("managed_paths", {})
478
- if not isinstance(managed_paths, dict):
479
- managed_paths = {}
478
+ managed_links = state.managed_links
479
+ managed_paths = state.managed_paths
480
480
 
481
481
  has_config = ws_source.has_any_config()
482
482
  if not has_config and not repos:
@@ -157,8 +157,8 @@ class StatusService:
157
157
  root=project_config_dir(source_repo, project_name)
158
158
  )
159
159
  state = project_source.load_state()
160
- has_managed_paths = bool(state.get("managed_paths"))
161
- has_managed_links = bool(state.get("managed_links"))
160
+ has_managed_paths = bool(state.managed_paths)
161
+ has_managed_links = bool(state.managed_links)
162
162
  has_project_actions = any(
163
163
  f":{project_name}:" in (action.scope or "")
164
164
  for action in project_actions
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-agnostic
3
- Version: 0.6.0
3
+ Version: 0.8.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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "code-agnostic"
7
- version = "0.6.0"
7
+ version = "0.8.0"
8
8
  description = "Centralized hub for LLM coding config: MCP, skills, rules, and agents."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -6,6 +6,7 @@ import json
6
6
  from pathlib import Path
7
7
 
8
8
  from code_agnostic.__main__ import cli
9
+ from code_agnostic.core.repository import CoreRepository
9
10
 
10
11
 
11
12
  def _write_skill(core_root: Path, name: str) -> None:
@@ -67,3 +68,50 @@ def test_disable_cursor_removes_skill_and_prunes_owned_mcp(
67
68
  # Flag is off.
68
69
  apps_cfg = json.loads((core_root / "config" / "apps.json").read_text())
69
70
  assert apps_cfg["cursor"] is False
71
+
72
+
73
+ def test_disable_claude_prunes_project_mcp_keeps_user_project(
74
+ minimal_shared_config: Path,
75
+ core_root: Path,
76
+ tmp_path: Path,
77
+ cli_runner,
78
+ enable_app,
79
+ ) -> None:
80
+ enable_app("claude")
81
+ workspace_root = tmp_path / "workspace"
82
+ (workspace_root / "repo-a" / ".git").mkdir(parents=True)
83
+
84
+ core = CoreRepository(core_root)
85
+ core.add_workspace("myws", workspace_root)
86
+ (core.workspace_config_dir("myws") / "mcp.base.json").write_text(
87
+ json.dumps({"mcpServers": {"ws-server": {"url": "https://example.com/mcp"}}}),
88
+ encoding="utf-8",
89
+ )
90
+
91
+ # User's own project entry — must survive disable untouched.
92
+ config_path = tmp_path / ".claude.json"
93
+ user_project = str((tmp_path / "user-repo").resolve())
94
+ config_path.write_text(
95
+ json.dumps(
96
+ {"projects": {user_project: {"mcpServers": {"personal": {"type": "http"}}}}}
97
+ ),
98
+ encoding="utf-8",
99
+ )
100
+
101
+ assert cli_runner.invoke(cli, ["apply", "-a", "claude"]).exit_code == 0
102
+
103
+ projects = json.loads(config_path.read_text())["projects"]
104
+ repo_key = str((workspace_root / "repo-a").resolve())
105
+ assert "mcpServers" in projects[repo_key]
106
+ state = json.loads((core_root / ".sync-state.json").read_text())
107
+ assert repo_key in state["managed_mcp"]["app:claude:projects"]
108
+
109
+ # Disable → our project mcpServers pruned, user's project kept.
110
+ assert cli_runner.invoke(cli, ["apps", "disable", "-a", "claude"]).exit_code == 0
111
+
112
+ projects = json.loads(config_path.read_text())["projects"]
113
+ assert "mcpServers" not in projects.get(repo_key, {})
114
+ assert projects[user_project] == {"mcpServers": {"personal": {"type": "http"}}}
115
+
116
+ state = json.loads((core_root / ".sync-state.json").read_text())
117
+ assert "app:claude:projects" not in state.get("managed_mcp", {})
@@ -5,6 +5,7 @@ import pytest
5
5
 
6
6
  from code_agnostic.errors import InvalidConfigSchemaError, InvalidJsonFormatError
7
7
  from code_agnostic.core.repository import CoreRepository
8
+ from code_agnostic.models import SyncState
8
9
 
9
10
 
10
11
  @pytest.fixture
@@ -17,14 +18,7 @@ def test_load_state_defaults_when_missing(core_repo: CoreRepository) -> None:
17
18
 
18
19
  state = repo.load_state()
19
20
 
20
- assert state == {
21
- "managed_skill_links": [],
22
- "managed_agent_links": [],
23
- "managed_workspace_links": [],
24
- "managed_links": {},
25
- "managed_paths": {},
26
- "managed_mcp": {},
27
- }
21
+ assert state == SyncState()
28
22
 
29
23
 
30
24
  def test_add_and_remove_workspace_persists_config(
@@ -203,14 +197,7 @@ def test_load_state_with_corrupted_json(
203
197
 
204
198
  state = repo.load_state()
205
199
 
206
- assert state == {
207
- "managed_skill_links": [],
208
- "managed_agent_links": [],
209
- "managed_workspace_links": [],
210
- "managed_links": {},
211
- "managed_paths": {},
212
- "managed_mcp": {},
213
- }
200
+ assert state == SyncState()
214
201
 
215
202
 
216
203
  def test_load_state_coerces_managed_skill_links_string_to_list(
@@ -224,7 +211,7 @@ def test_load_state_coerces_managed_skill_links_string_to_list(
224
211
 
225
212
  state = repo.load_state()
226
213
 
227
- assert state["managed_skill_links"] == []
214
+ assert state.managed_skill_links == []
228
215
 
229
216
 
230
217
  def test_list_skill_sources_when_dir_missing(core_repo: CoreRepository) -> None:
@@ -150,7 +150,7 @@ def test_build_plan_and_apply_create_opencode_and_workspace_links(
150
150
 
151
151
  ws_repo = WorkspaceConfigRepository(root=ws_config_dir)
152
152
  ws_state = ws_repo.load_state()
153
- assert len(ws_state["managed_paths"]["rules"]) == 1
153
+ assert len(ws_state.managed_paths["rules"]) == 1
154
154
 
155
155
 
156
156
  def test_plan_marks_config_create_when_missing(
@@ -283,9 +283,7 @@ def test_targeted_execute_preserves_workspace_state_for_other_apps(
283
283
  assert failures == []
284
284
  assert applied > 0
285
285
 
286
- managed_paths = WorkspaceConfigRepository(root=ws_config).load_state()[
287
- "managed_paths"
288
- ]
286
+ managed_paths = WorkspaceConfigRepository(root=ws_config).load_state().managed_paths
289
287
  assert "ws:codex:workspace_root_mcp" in managed_paths
290
288
  assert "ws:codex:repo_mcp" in managed_paths
291
289
  assert "ws:opencode:workspace_root_mcp" in managed_paths
@@ -5,6 +5,7 @@ from code_agnostic.models import (
5
5
  ActionKind,
6
6
  ActionStatus,
7
7
  SyncPlan,
8
+ SyncState,
8
9
  )
9
10
 
10
11
 
@@ -205,3 +206,46 @@ def test_is_valid_with_errors() -> None:
205
206
  plan = SyncPlan(actions=[], errors=[ValueError("err")], skipped=[])
206
207
 
207
208
  assert plan.is_valid() is False
209
+
210
+
211
+ def test_sync_state_from_non_dict_payload_is_empty() -> None:
212
+ assert SyncState.from_payload(None) == SyncState()
213
+ assert SyncState.from_payload("garbage") == SyncState()
214
+ assert SyncState.from_payload([1, 2, 3]) == SyncState()
215
+
216
+
217
+ def test_sync_state_from_payload_normalizes_all_fields() -> None:
218
+ state = SyncState.from_payload(
219
+ {
220
+ "managed_links": {"app:cursor:skills": ["/a", 5, "/b"]},
221
+ "managed_paths": {"scope": ["/p"], 7: ["ignored"]},
222
+ "managed_mcp": {"app:claude:projects": ["/repo"]},
223
+ "managed_skill_links": ["x", 1, "y"],
224
+ "skipped": ["s1"],
225
+ "updated_at": "2026-07-21T00:00:00",
226
+ }
227
+ )
228
+
229
+ # non-str entries and non-str scope keys are dropped
230
+ assert state.managed_links == {"app:cursor:skills": ["/a", "/b"]}
231
+ assert state.managed_paths == {"scope": ["/p"]}
232
+ assert state.managed_mcp == {"app:claude:projects": ["/repo"]}
233
+ assert state.managed_skill_links == ["x", "y"]
234
+ assert state.skipped == ["s1"]
235
+ assert state.updated_at == "2026-07-21T00:00:00"
236
+
237
+
238
+ def test_sync_state_from_payload_coerces_wrong_types() -> None:
239
+ state = SyncState.from_payload(
240
+ {
241
+ "managed_links": "not-a-dict",
242
+ "managed_paths": {"scope": "not-a-list"},
243
+ "managed_skill_links": "not-a-list",
244
+ "updated_at": 12345,
245
+ }
246
+ )
247
+
248
+ assert state.managed_links == {}
249
+ assert state.managed_paths == {} # non-list value drops the scope
250
+ assert state.managed_skill_links == []
251
+ assert state.updated_at is None