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