code-agnostic 0.7.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.7.0 → code_agnostic-0.8.0}/PKG-INFO +1 -1
  2. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/__init__.py +1 -1
  3. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/apps/apps_service.py +7 -15
  4. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/apps/common/interfaces/repositories.py +3 -1
  5. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/apps/common/interfaces/service.py +3 -7
  6. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/core/repository.py +5 -29
  7. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/executor.py +7 -7
  8. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/models.py +63 -1
  9. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/planner.py +6 -20
  10. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/status.py +2 -2
  11. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic.egg-info/PKG-INFO +1 -1
  12. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/pyproject.toml +1 -1
  13. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/tests/test_common_repository.py +4 -17
  14. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/tests/test_planner_executor.py +2 -4
  15. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/tests/test_sync_plan_model.py +44 -0
  16. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/tests/test_workspace_config_sync.py +9 -9
  17. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/LICENSE +0 -0
  18. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/README.md +0 -0
  19. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/__main__.py +0 -0
  20. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/agents/__init__.py +0 -0
  21. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/agents/claude.py +0 -0
  22. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/agents/codex.py +0 -0
  23. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/agents/compilers.py +0 -0
  24. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/agents/copilot.py +0 -0
  25. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/agents/models.py +0 -0
  26. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/agents/opencode.py +0 -0
  27. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/agents/parser.py +0 -0
  28. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/apps/__init__.py +0 -0
  29. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/apps/app_id.py +0 -0
  30. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/apps/claude/__init__.py +0 -0
  31. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/apps/claude/config_repository.py +0 -0
  32. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/apps/claude/mapper.py +0 -0
  33. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/apps/claude/service.py +0 -0
  34. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/apps/codex/__init__.py +0 -0
  35. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/apps/codex/config_repository.py +0 -0
  36. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/apps/codex/mapper.py +0 -0
  37. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/apps/codex/schema.json +0 -0
  38. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/apps/codex/schema_repository.py +0 -0
  39. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/apps/codex/service.py +0 -0
  40. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/apps/common/__init__.py +0 -0
  41. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/apps/common/compiled_planning.py +0 -0
  42. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/apps/common/framework.py +0 -0
  43. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/apps/common/interfaces/__init__.py +0 -0
  44. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/apps/common/interfaces/mapper.py +0 -0
  45. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/apps/common/loader.py +0 -0
  46. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/apps/common/models.py +0 -0
  47. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/apps/common/schema.py +0 -0
  48. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/apps/common/symlink_planning.py +0 -0
  49. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/apps/common/utils.py +0 -0
  50. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/apps/copilot/__init__.py +0 -0
  51. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/apps/copilot/config_repository.py +0 -0
  52. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/apps/copilot/mapper.py +0 -0
  53. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/apps/copilot/service.py +0 -0
  54. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/apps/cursor/__init__.py +0 -0
  55. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/apps/cursor/config_repository.py +0 -0
  56. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/apps/cursor/mapper.py +0 -0
  57. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/apps/cursor/schema.json +0 -0
  58. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/apps/cursor/schema_repository.py +0 -0
  59. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/apps/cursor/service.py +0 -0
  60. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/apps/opencode/__init__.py +0 -0
  61. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/apps/opencode/config_repository.py +0 -0
  62. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/apps/opencode/mapper.py +0 -0
  63. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/apps/opencode/schema.json +0 -0
  64. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/apps/opencode/schema_repository.py +0 -0
  65. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/apps/opencode/service.py +0 -0
  66. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/cli/__init__.py +0 -0
  67. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/cli/aliases.py +0 -0
  68. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/cli/commands/__init__.py +0 -0
  69. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/cli/commands/agents.py +0 -0
  70. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/cli/commands/apply.py +0 -0
  71. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/cli/commands/apps.py +0 -0
  72. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/cli/commands/explain_lossiness.py +0 -0
  73. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/cli/commands/import_.py +0 -0
  74. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/cli/commands/mcp.py +0 -0
  75. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/cli/commands/plan.py +0 -0
  76. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/cli/commands/projects.py +0 -0
  77. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/cli/commands/restore.py +0 -0
  78. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/cli/commands/rules.py +0 -0
  79. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/cli/commands/skills.py +0 -0
  80. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/cli/commands/status.py +0 -0
  81. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/cli/commands/validate.py +0 -0
  82. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/cli/commands/workspaces.py +0 -0
  83. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/cli/helpers.py +0 -0
  84. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/cli/options.py +0 -0
  85. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/constants.py +0 -0
  86. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/core/__init__.py +0 -0
  87. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/core/project_repository.py +0 -0
  88. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/core/workspace_repository.py +0 -0
  89. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/errors.py +0 -0
  90. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/generated_artifacts.py +0 -0
  91. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/git_exclude_service.py +0 -0
  92. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/imports/__init__.py +0 -0
  93. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/imports/adapters.py +0 -0
  94. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/imports/filesystem.py +0 -0
  95. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/imports/models.py +0 -0
  96. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/imports/service.py +0 -0
  97. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/lossiness.py +0 -0
  98. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/mcp_service.py +0 -0
  99. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/project_artifacts.py +0 -0
  100. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/rules/__init__.py +0 -0
  101. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/rules/compilers.py +0 -0
  102. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/rules/models.py +0 -0
  103. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/rules/parser.py +0 -0
  104. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/rules/repository.py +0 -0
  105. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/skills/__init__.py +0 -0
  106. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/skills/compilers.py +0 -0
  107. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/skills/install_sources.py +0 -0
  108. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/skills/models.py +0 -0
  109. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/skills/parser.py +0 -0
  110. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/spec/__init__.py +0 -0
  111. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/spec/loaders.py +0 -0
  112. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/spec/schemas/agent.v1.schema.json +0 -0
  113. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/spec/schemas/mcp.base.schema.json +0 -0
  114. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/spec/schemas/mcp.v1.schema.json +0 -0
  115. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/spec/schemas/rule.v1.schema.json +0 -0
  116. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/spec/schemas/skill.v1.schema.json +0 -0
  117. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/tui/__init__.py +0 -0
  118. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/tui/enums.py +0 -0
  119. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/tui/import_selector.py +0 -0
  120. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/tui/renderers.py +0 -0
  121. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/tui/sections.py +0 -0
  122. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/tui/tables.py +0 -0
  123. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/utils.py +0 -0
  124. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/validation.py +0 -0
  125. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/workspace_artifacts.py +0 -0
  126. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic/workspaces.py +0 -0
  127. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic.egg-info/SOURCES.txt +0 -0
  128. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic.egg-info/dependency_links.txt +0 -0
  129. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic.egg-info/entry_points.txt +0 -0
  130. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic.egg-info/requires.txt +0 -0
  131. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/code_agnostic.egg-info/top_level.txt +0 -0
  132. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/setup.cfg +0 -0
  133. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/tests/test_cli_agents.py +0 -0
  134. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/tests/test_cli_aliases.py +0 -0
  135. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/tests/test_cli_apply_apps.py +0 -0
  136. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/tests/test_cli_apply_codex.py +0 -0
  137. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/tests/test_cli_apply_codex_agent_cleanup.py +0 -0
  138. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/tests/test_cli_apply_cursor.py +0 -0
  139. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/tests/test_cli_apply_mcp_cleanup.py +0 -0
  140. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/tests/test_cli_apply_target.py +0 -0
  141. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/tests/test_cli_apps.py +0 -0
  142. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/tests/test_cli_disable_cleanup.py +0 -0
  143. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/tests/test_cli_explain_lossiness.py +0 -0
  144. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/tests/test_cli_flags.py +0 -0
  145. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/tests/test_cli_git_exclude.py +0 -0
  146. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/tests/test_cli_import.py +0 -0
  147. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/tests/test_cli_import_interactive.py +0 -0
  148. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/tests/test_cli_mcp.py +0 -0
  149. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/tests/test_cli_module_organization.py +0 -0
  150. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/tests/test_cli_plan.py +0 -0
  151. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/tests/test_cli_projects.py +0 -0
  152. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/tests/test_cli_restore.py +0 -0
  153. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/tests/test_cli_rules.py +0 -0
  154. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/tests/test_cli_skills.py +0 -0
  155. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/tests/test_cli_status.py +0 -0
  156. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/tests/test_cli_validate.py +0 -0
  157. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/tests/test_cli_workspace_resolution.py +0 -0
  158. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/tests/test_cli_workspaces.py +0 -0
  159. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/tests/test_common_mcp_to_dto.py +0 -0
  160. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/tests/test_compiled_planning.py +0 -0
  161. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/tests/test_dto_to_common_mcp.py +0 -0
  162. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/tests/test_git_exclude_service.py +0 -0
  163. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/tests/test_mcp_service.py +0 -0
  164. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/tests/test_planner_rules.py +0 -0
  165. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/tests/test_project_config_sync.py +0 -0
  166. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/tests/test_skill_install_sources.py +0 -0
  167. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/tests/test_symlink_planning.py +0 -0
  168. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/tests/test_transactional_executor.py +0 -0
  169. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/tests/test_utils.py +0 -0
  170. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/tests/test_version.py +0 -0
  171. {code_agnostic-0.7.0 → code_agnostic-0.8.0}/tests/test_workspace_repo_status.py +0 -0
  172. {code_agnostic-0.7.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.7.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.7.0"
3
+ __version__ = "0.8.0"
@@ -103,9 +103,9 @@ class AppsService:
103
103
 
104
104
  # --- Global scopes (app:<app>:*) ---
105
105
  global_state = core.load_state()
106
- global_links = self._normalize_group(global_state.get("managed_links"))
107
- global_paths = self._normalize_group(global_state.get("managed_paths"))
108
- 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
109
109
  global_prefix = f"app:{app_id.value}:"
110
110
  for scope in sorted(s for s in global_links if s.startswith(global_prefix)):
111
111
  actions.extend(self._cleanup_links(global_links, scope, app_id.value))
@@ -123,8 +123,8 @@ class AppsService:
123
123
  ws_name = workspace["name"]
124
124
  ws_repo = WorkspaceConfigRepository(root=core.workspace_config_dir(ws_name))
125
125
  ws_state = ws_repo.load_state()
126
- ws_links = self._normalize_group(ws_state.get("managed_links"))
127
- ws_paths = self._normalize_group(ws_state.get("managed_paths"))
126
+ ws_links = ws_state.managed_links
127
+ ws_paths = ws_state.managed_paths
128
128
  for scope in sorted(s for s in ws_links if s.startswith(ws_prefix)):
129
129
  for action in self._cleanup_links(ws_links, scope, "workspace"):
130
130
  action.workspace = ws_name
@@ -144,8 +144,8 @@ class AppsService:
144
144
  root=project_config_dir(core, project_name)
145
145
  )
146
146
  project_state = project_repo.load_state()
147
- project_links = self._normalize_group(project_state.get("managed_links"))
148
- project_paths = self._normalize_group(project_state.get("managed_paths"))
147
+ project_links = project_state.managed_links
148
+ project_paths = project_state.managed_paths
149
149
  for scope in sorted(
150
150
  s for s in project_links if s.startswith(project_prefix)
151
151
  ):
@@ -163,14 +163,6 @@ class AppsService:
163
163
 
164
164
  return SyncPlan(actions=actions, errors=[], skipped=skipped)
165
165
 
166
- @staticmethod
167
- def _normalize_group(value: Any) -> dict[str, Any]:
168
- return (
169
- {scope: paths for scope, paths in value.items() if isinstance(scope, str)}
170
- if isinstance(value, dict)
171
- else {}
172
- )
173
-
174
166
  @staticmethod
175
167
  def _cleanup_links(group: dict[str, Any], scope: str, app: str) -> list[Action]:
176
168
  return plan_stale_group(
@@ -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
@@ -284,14 +284,8 @@ class SyncPlanner:
284
284
  else None
285
285
  )
286
286
  state = self.core.load_state()
287
- managed_mcp = state.get("managed_mcp")
288
- if not isinstance(managed_mcp, dict):
289
- managed_mcp = {}
290
- prev_projects_raw = managed_mcp.get(app_scope(AppId.CLAUDE, "projects"), [])
291
- previously_managed_projects = (
292
- {path for path in prev_projects_raw if isinstance(path, str)}
293
- if isinstance(prev_projects_raw, list)
294
- else set()
287
+ previously_managed_projects = set(
288
+ state.managed_mcp.get(app_scope(AppId.CLAUDE, "projects"), [])
295
289
  )
296
290
  project_action = claude_service.build_project_mcp_action(
297
291
  self._claude_project_mcp,
@@ -366,12 +360,8 @@ class SyncPlanner:
366
360
  skill_sources = project_source.list_skill_sources()
367
361
 
368
362
  state = project_source.load_state()
369
- managed_links = state.get("managed_links", {})
370
- if not isinstance(managed_links, dict):
371
- managed_links = {}
372
- managed_paths = state.get("managed_paths", {})
373
- if not isinstance(managed_paths, dict):
374
- managed_paths = {}
363
+ managed_links = state.managed_links
364
+ managed_paths = state.managed_paths
375
365
 
376
366
  actions: list[Action] = []
377
367
  skipped: list[str] = []
@@ -485,12 +475,8 @@ class SyncPlanner:
485
475
 
486
476
  repos = self.workspace_service.discover_git_repos(workspace_path)
487
477
  state = ws_source.load_state()
488
- managed_links = state.get("managed_links", {})
489
- if not isinstance(managed_links, dict):
490
- managed_links = {}
491
- managed_paths = state.get("managed_paths", {})
492
- if not isinstance(managed_paths, dict):
493
- managed_paths = {}
478
+ managed_links = state.managed_links
479
+ managed_paths = state.managed_paths
494
480
 
495
481
  has_config = ws_source.has_any_config()
496
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.7.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.7.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"
@@ -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
@@ -32,7 +32,7 @@ from code_agnostic.constants import (
32
32
  from code_agnostic.core.repository import CoreRepository
33
33
  from code_agnostic.core.workspace_repository import WorkspaceConfigRepository
34
34
  from code_agnostic.executor import SyncExecutor
35
- from code_agnostic.models import ActionKind, ActionStatus
35
+ from code_agnostic.models import ActionKind, ActionStatus, SyncState
36
36
  from code_agnostic.planner import SyncPlanner
37
37
 
38
38
 
@@ -153,11 +153,11 @@ def test_workspace_config_repository_state_roundtrip(tmp_path: Path) -> None:
153
153
  repo = WorkspaceConfigRepository(root=ws_root)
154
154
 
155
155
  state = repo.load_state()
156
- assert isinstance(state, dict)
156
+ assert isinstance(state, SyncState)
157
157
 
158
158
  repo.save_state({"managed_links": {"rules": ["/path/to/link"]}})
159
159
  loaded = repo.load_state()
160
- assert loaded["managed_links"]["rules"] == ["/path/to/link"]
160
+ assert loaded.managed_links["rules"] == ["/path/to/link"]
161
161
 
162
162
 
163
163
  # --- Workspace rules files ---
@@ -920,8 +920,8 @@ def test_workspace_targeted_plan_cleans_stale_cursor_workspace_outputs(
920
920
  assert not stale_mcp.exists()
921
921
  assert not stale_agent.exists()
922
922
  state = ws_repo.load_state()
923
- assert "ws:cursor:repo_mcp" not in state["managed_paths"]
924
- assert "ws:cursor:repo_agents_dir" not in state["managed_links"]
923
+ assert "ws:cursor:repo_mcp" not in state.managed_paths
924
+ assert "ws:cursor:repo_agents_dir" not in state.managed_links
925
925
 
926
926
 
927
927
  def test_workspace_plan_skips_legacy_link_cleanup_when_same_path_is_now_generated(
@@ -1419,13 +1419,13 @@ def test_executor_persists_workspace_state_separately(
1419
1419
  # Workspace state persisted to workspace state file.
1420
1420
  ws_repo = WorkspaceConfigRepository(root=ws_config)
1421
1421
  ws_state = ws_repo.load_state()
1422
- managed = ws_state["managed_paths"]
1422
+ managed = ws_state.managed_paths
1423
1423
  assert "rules" in managed
1424
1424
  assert len(managed["rules"]) == 1
1425
1425
 
1426
1426
  # Global state should not contain workspace links
1427
1427
  global_state = core.load_state()
1428
- assert "rules" not in global_state.get("managed_links", {})
1428
+ assert "rules" not in global_state.managed_links
1429
1429
 
1430
1430
 
1431
1431
  # --- Full roundtrip with apply ---
@@ -1548,7 +1548,7 @@ def test_workspace_stale_skills_cleanup_when_skills_removed_for_codex(
1548
1548
  # Verify state was persisted with workspace scopes
1549
1549
  ws_repo = WorkspaceConfigRepository(root=ws_config)
1550
1550
  state = ws_repo.load_state()
1551
- assert "ws:codex:repo_skills_dir" in state["managed_paths"]
1551
+ assert "ws:codex:repo_skills_dir" in state.managed_paths
1552
1552
 
1553
1553
  # Remove all skills from workspace config
1554
1554
  import shutil
@@ -1597,7 +1597,7 @@ def test_workspace_stale_skills_cleanup_when_skills_removed_for_claude(
1597
1597
 
1598
1598
  ws_repo = WorkspaceConfigRepository(root=ws_config)
1599
1599
  state = ws_repo.load_state()
1600
- assert "ws:claude:repo_skills_dir" in state["managed_paths"]
1600
+ assert "ws:claude:repo_skills_dir" in state.managed_paths
1601
1601
 
1602
1602
  import shutil
1603
1603
 
File without changes
File without changes
File without changes