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.
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/PKG-INFO +1 -1
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/__init__.py +1 -1
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/apps_service.py +31 -15
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/claude/service.py +19 -2
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/common/interfaces/repositories.py +3 -1
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/common/interfaces/service.py +3 -7
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/core/repository.py +5 -29
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/executor.py +7 -7
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/models.py +63 -1
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/planner.py +16 -16
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/status.py +2 -2
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic.egg-info/PKG-INFO +1 -1
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/pyproject.toml +1 -1
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_disable_cleanup.py +48 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_common_repository.py +4 -17
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_planner_executor.py +2 -4
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_sync_plan_model.py +44 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_workspace_config_sync.py +86 -9
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/LICENSE +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/README.md +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/__main__.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/agents/__init__.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/agents/claude.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/agents/codex.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/agents/compilers.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/agents/copilot.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/agents/models.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/agents/opencode.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/agents/parser.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/__init__.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/app_id.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/claude/__init__.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/claude/config_repository.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/claude/mapper.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/codex/__init__.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/codex/config_repository.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/codex/mapper.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/codex/schema.json +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/codex/schema_repository.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/codex/service.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/common/__init__.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/common/compiled_planning.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/common/framework.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/common/interfaces/__init__.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/common/interfaces/mapper.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/common/loader.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/common/models.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/common/schema.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/common/symlink_planning.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/common/utils.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/copilot/__init__.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/copilot/config_repository.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/copilot/mapper.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/copilot/service.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/cursor/__init__.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/cursor/config_repository.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/cursor/mapper.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/cursor/schema.json +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/cursor/schema_repository.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/cursor/service.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/opencode/__init__.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/opencode/config_repository.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/opencode/mapper.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/opencode/schema.json +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/opencode/schema_repository.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/opencode/service.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/cli/__init__.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/cli/aliases.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/cli/commands/__init__.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/cli/commands/agents.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/cli/commands/apply.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/cli/commands/apps.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/cli/commands/explain_lossiness.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/cli/commands/import_.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/cli/commands/mcp.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/cli/commands/plan.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/cli/commands/projects.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/cli/commands/restore.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/cli/commands/rules.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/cli/commands/skills.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/cli/commands/status.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/cli/commands/validate.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/cli/commands/workspaces.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/cli/helpers.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/cli/options.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/constants.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/core/__init__.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/core/project_repository.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/core/workspace_repository.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/errors.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/generated_artifacts.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/git_exclude_service.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/imports/__init__.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/imports/adapters.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/imports/filesystem.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/imports/models.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/imports/service.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/lossiness.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/mcp_service.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/project_artifacts.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/rules/__init__.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/rules/compilers.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/rules/models.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/rules/parser.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/rules/repository.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/skills/__init__.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/skills/compilers.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/skills/install_sources.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/skills/models.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/skills/parser.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/spec/__init__.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/spec/loaders.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/spec/schemas/agent.v1.schema.json +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/spec/schemas/mcp.base.schema.json +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/spec/schemas/mcp.v1.schema.json +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/spec/schemas/rule.v1.schema.json +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/spec/schemas/skill.v1.schema.json +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/tui/__init__.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/tui/enums.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/tui/import_selector.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/tui/renderers.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/tui/sections.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/tui/tables.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/utils.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/validation.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/workspace_artifacts.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/workspaces.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic.egg-info/SOURCES.txt +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic.egg-info/dependency_links.txt +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic.egg-info/entry_points.txt +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic.egg-info/requires.txt +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic.egg-info/top_level.txt +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/setup.cfg +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_agents.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_aliases.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_apply_apps.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_apply_codex.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_apply_codex_agent_cleanup.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_apply_cursor.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_apply_mcp_cleanup.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_apply_target.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_apps.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_explain_lossiness.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_flags.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_git_exclude.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_import.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_import_interactive.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_mcp.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_module_organization.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_plan.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_projects.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_restore.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_rules.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_skills.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_status.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_validate.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_workspace_resolution.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_cli_workspaces.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_common_mcp_to_dto.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_compiled_planning.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_dto_to_common_mcp.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_git_exclude_service.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_mcp_service.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_planner_rules.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_project_config_sync.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_skill_install_sources.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_symlink_planning.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_transactional_executor.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_utils.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_version.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_workspace_repo_status.py +0 -0
- {code_agnostic-0.6.0 → code_agnostic-0.8.0}/tests/test_workspaces.py +0 -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 =
|
|
106
|
-
global_paths =
|
|
107
|
-
global_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 =
|
|
124
|
-
ws_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 =
|
|
145
|
-
project_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
|
-
|
|
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,
|
{code_agnostic-0.6.0 → code_agnostic-0.8.0}/code_agnostic/apps/common/interfaces/repositories.py
RENAMED
|
@@ -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) ->
|
|
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 =
|
|
312
|
-
managed_paths_group =
|
|
313
|
-
managed_mcp_group =
|
|
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) ->
|
|
114
|
+
def load_state(self) -> SyncState:
|
|
114
115
|
payload, error = read_json_safe(self.state_json)
|
|
115
|
-
if error is not None
|
|
116
|
-
return
|
|
117
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
294
|
-
project_action.
|
|
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.
|
|
356
|
-
|
|
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.
|
|
475
|
-
|
|
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.
|
|
161
|
-
has_managed_links = bool(state.
|
|
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
|
|
@@ -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
|
|
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
|
|
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
|