code-agnostic 0.3.7__tar.gz → 0.3.8__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.7 → code_agnostic-0.3.8}/PKG-INFO +8 -6
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/README.md +5 -5
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/__init__.py +1 -1
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/agents/codex.py +6 -3
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/apps/app_id.py +1 -1
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/apps/claude/service.py +2 -66
- code_agnostic-0.3.8/code_agnostic/apps/common/compiled_planning.py +115 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/apps/common/interfaces/repositories.py +10 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/apps/common/interfaces/service.py +30 -14
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/apps/opencode/service.py +2 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/cli/commands/workspaces.py +6 -1
- code_agnostic-0.3.8/code_agnostic/generated_artifacts.py +134 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/git_exclude_service.py +23 -41
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/imports/adapters.py +10 -10
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/planner.py +15 -12
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/status.py +11 -60
- code_agnostic-0.3.8/code_agnostic/workspace_artifacts.py +238 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic.egg-info/PKG-INFO +8 -6
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic.egg-info/SOURCES.txt +2 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic.egg-info/requires.txt +2 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/pyproject.toml +4 -2
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/tests/test_cli_apply_apps.py +38 -12
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/tests/test_cli_plan.py +14 -4
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/tests/test_cli_workspaces.py +53 -15
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/tests/test_git_exclude_service.py +31 -9
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/tests/test_workspace_config_sync.py +120 -16
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/tests/test_workspace_repo_status.py +57 -11
- code_agnostic-0.3.7/code_agnostic/apps/common/compiled_planning.py +0 -197
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/LICENSE +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/__main__.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/agents/__init__.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/agents/claude.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/agents/compilers.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/agents/models.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/agents/opencode.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/agents/parser.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/apps/__init__.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/apps/apps_service.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/apps/claude/__init__.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/apps/claude/config_repository.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/apps/claude/mapper.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/apps/codex/__init__.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/apps/codex/config_repository.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/apps/codex/mapper.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/apps/codex/schema.json +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/apps/codex/schema_repository.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/apps/codex/service.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/apps/common/__init__.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/apps/common/framework.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/apps/common/interfaces/__init__.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/apps/common/interfaces/mapper.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/apps/common/loader.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/apps/common/models.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/apps/common/schema.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/apps/common/symlink_planning.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/apps/common/utils.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/apps/cursor/__init__.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/apps/cursor/config_repository.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/apps/cursor/mapper.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/apps/cursor/schema.json +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/apps/cursor/schema_repository.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/apps/cursor/service.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/apps/opencode/__init__.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/apps/opencode/config_repository.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/apps/opencode/mapper.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/apps/opencode/schema.json +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/apps/opencode/schema_repository.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/cli/__init__.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/cli/aliases.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/cli/commands/__init__.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/cli/commands/agents.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/cli/commands/apply.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/cli/commands/apps.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/cli/commands/explain_lossiness.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/cli/commands/import_.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/cli/commands/mcp.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/cli/commands/plan.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/cli/commands/restore.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/cli/commands/rules.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/cli/commands/skills.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/cli/commands/status.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/cli/commands/validate.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/cli/helpers.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/cli/options.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/constants.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/core/__init__.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/core/repository.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/core/workspace_repository.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/errors.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/executor.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/imports/__init__.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/imports/filesystem.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/imports/models.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/imports/service.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/lossiness.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/mcp_service.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/models.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/rules/__init__.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/rules/compilers.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/rules/models.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/rules/parser.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/rules/repository.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/skills/__init__.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/skills/compilers.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/skills/models.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/skills/parser.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/spec/__init__.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/spec/loaders.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/spec/schemas/agent.v1.schema.json +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/spec/schemas/mcp.base.schema.json +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/spec/schemas/mcp.v1.schema.json +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/spec/schemas/rule.v1.schema.json +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/spec/schemas/skill.v1.schema.json +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/tui/__init__.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/tui/enums.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/tui/import_selector.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/tui/renderers.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/tui/sections.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/tui/tables.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/utils.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/validation.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/workspaces.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic.egg-info/dependency_links.txt +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic.egg-info/entry_points.txt +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic.egg-info/top_level.txt +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/setup.cfg +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/tests/test_cli_agents.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/tests/test_cli_aliases.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/tests/test_cli_apply_codex.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/tests/test_cli_apply_cursor.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/tests/test_cli_apply_target.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/tests/test_cli_apps.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/tests/test_cli_explain_lossiness.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/tests/test_cli_flags.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/tests/test_cli_git_exclude.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/tests/test_cli_import.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/tests/test_cli_import_interactive.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/tests/test_cli_mcp.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/tests/test_cli_module_organization.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/tests/test_cli_restore.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/tests/test_cli_rules.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/tests/test_cli_skills.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/tests/test_cli_status.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/tests/test_cli_validate.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/tests/test_cli_workspace_resolution.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/tests/test_common_mcp_to_dto.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/tests/test_common_repository.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/tests/test_compiled_planning.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/tests/test_dto_to_common_mcp.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/tests/test_mcp_service.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/tests/test_planner_executor.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/tests/test_planner_rules.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/tests/test_symlink_planning.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/tests/test_sync_plan_model.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/tests/test_transactional_executor.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/tests/test_utils.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/tests/test_version.py +0 -0
- {code_agnostic-0.3.7 → code_agnostic-0.3.8}/tests/test_workspaces.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: code-agnostic
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.8
|
|
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
|
|
@@ -19,6 +19,8 @@ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
|
19
19
|
Requires-Dist: jsonschema>=4.0; extra == "dev"
|
|
20
20
|
Requires-Dist: ruff>=0.8.0; extra == "dev"
|
|
21
21
|
Requires-Dist: mypy>=1.11; extra == "dev"
|
|
22
|
+
Requires-Dist: types-jsonschema>=4.0; extra == "dev"
|
|
23
|
+
Requires-Dist: types-PyYAML>=6.0; extra == "dev"
|
|
22
24
|
Dynamic: license-file
|
|
23
25
|
|
|
24
26
|
# code-agnostic
|
|
@@ -115,15 +117,15 @@ code-agnostic apply
|
|
|
115
117
|
| Native repo config include for workspace `AGENTS.md` | yes | -- | -- | -- |
|
|
116
118
|
| Repo/subdir gets shared workspace instructions today | yes | -- | yes | yes |
|
|
117
119
|
| Nested `AGENTS.md` discovery | -- | yes | yes | -- |
|
|
118
|
-
| Workspace propagation | yes |
|
|
120
|
+
| Workspace propagation | yes | yes | yes | yes |
|
|
119
121
|
| Import from | yes | yes | yes | yes |
|
|
120
122
|
| Interactive import (TUI) | yes | yes | yes | yes |
|
|
121
123
|
|
|
122
|
-
Cursor workspace propagation
|
|
124
|
+
Cursor workspace propagation writes repo-local MCP, skills, and agents when those resources exist in the workspace source config.
|
|
123
125
|
|
|
124
126
|
OpenCode workspace configs include the shared workspace `AGENTS.md` natively via `instructions`, so repos under the workspace get both repo-local and shared workspace instructions. Codex repos receive workspace instructions through a generated `AGENTS.override.md`, which is added to each repo's `.git/info/exclude`. Claude Code receives workspace instructions through generated `CLAUDE.local.md` files, never by editing committed `CLAUDE.md`.
|
|
125
127
|
|
|
126
|
-
Cursor documents `AGENTS.md` support in project roots and subdirectories. `code-agnostic`
|
|
128
|
+
Cursor documents `AGENTS.md` support in project roots and subdirectories. `code-agnostic` does not copy or link the shared workspace `AGENTS.md` into child repos; Cursor will load repo-local or nested `AGENTS.md` files that already exist in the opened project. Codex documents nested `AGENTS.md` discovery, but not a native config include for an extra workspace file.
|
|
127
129
|
|
|
128
130
|
## Features
|
|
129
131
|
|
|
@@ -211,9 +213,9 @@ That command should copy the skill into the source of truth and then run the nor
|
|
|
211
213
|
|
|
212
214
|
### Workspaces
|
|
213
215
|
|
|
214
|
-
Register workspace directories. Workspace rules are compiled into a canonical `AGENTS.md` at the workspace root. Repos keep their own repo-specific `AGENTS.md`; Codex receives the workspace rules through generated, git-excluded `AGENTS.override.md` files, while OpenCode workspace configs reference the shared workspace file through `instructions`. Claude receives generated `CLAUDE.local.md` files and project MCP entries in `~/.claude.json["projects"][absolute_repo_path]["mcpServers"]`. Repo-local app config, skills, and agents are propagated for OpenCode, Codex, and Claude.
|
|
216
|
+
Register workspace directories. Workspace rules are compiled into a canonical `AGENTS.md` at the workspace root. Repos keep their own repo-specific `AGENTS.md`; Codex receives the workspace rules through generated, git-excluded `AGENTS.override.md` files, while OpenCode workspace configs reference the shared workspace file through `instructions`. Claude receives generated `CLAUDE.local.md` files and project MCP entries in `~/.claude.json["projects"][absolute_repo_path]["mcpServers"]`. Repo-local app config, skills, and agents are propagated for OpenCode, Cursor, Codex, and Claude.
|
|
215
217
|
|
|
216
|
-
|
|
218
|
+
Cursor propagation intentionally stays to repo-local MCP, skills, and agents; it does not copy the shared workspace `AGENTS.md` into child repos.
|
|
217
219
|
|
|
218
220
|
```bash
|
|
219
221
|
code-agnostic workspaces add --name myproject --path ~/code/myproject
|
|
@@ -92,15 +92,15 @@ code-agnostic apply
|
|
|
92
92
|
| Native repo config include for workspace `AGENTS.md` | yes | -- | -- | -- |
|
|
93
93
|
| Repo/subdir gets shared workspace instructions today | yes | -- | yes | yes |
|
|
94
94
|
| Nested `AGENTS.md` discovery | -- | yes | yes | -- |
|
|
95
|
-
| Workspace propagation | yes |
|
|
95
|
+
| Workspace propagation | yes | yes | yes | yes |
|
|
96
96
|
| Import from | yes | yes | yes | yes |
|
|
97
97
|
| Interactive import (TUI) | yes | yes | yes | yes |
|
|
98
98
|
|
|
99
|
-
Cursor workspace propagation
|
|
99
|
+
Cursor workspace propagation writes repo-local MCP, skills, and agents when those resources exist in the workspace source config.
|
|
100
100
|
|
|
101
101
|
OpenCode workspace configs include the shared workspace `AGENTS.md` natively via `instructions`, so repos under the workspace get both repo-local and shared workspace instructions. Codex repos receive workspace instructions through a generated `AGENTS.override.md`, which is added to each repo's `.git/info/exclude`. Claude Code receives workspace instructions through generated `CLAUDE.local.md` files, never by editing committed `CLAUDE.md`.
|
|
102
102
|
|
|
103
|
-
Cursor documents `AGENTS.md` support in project roots and subdirectories. `code-agnostic`
|
|
103
|
+
Cursor documents `AGENTS.md` support in project roots and subdirectories. `code-agnostic` does not copy or link the shared workspace `AGENTS.md` into child repos; Cursor will load repo-local or nested `AGENTS.md` files that already exist in the opened project. Codex documents nested `AGENTS.md` discovery, but not a native config include for an extra workspace file.
|
|
104
104
|
|
|
105
105
|
## Features
|
|
106
106
|
|
|
@@ -188,9 +188,9 @@ That command should copy the skill into the source of truth and then run the nor
|
|
|
188
188
|
|
|
189
189
|
### Workspaces
|
|
190
190
|
|
|
191
|
-
Register workspace directories. Workspace rules are compiled into a canonical `AGENTS.md` at the workspace root. Repos keep their own repo-specific `AGENTS.md`; Codex receives the workspace rules through generated, git-excluded `AGENTS.override.md` files, while OpenCode workspace configs reference the shared workspace file through `instructions`. Claude receives generated `CLAUDE.local.md` files and project MCP entries in `~/.claude.json["projects"][absolute_repo_path]["mcpServers"]`. Repo-local app config, skills, and agents are propagated for OpenCode, Codex, and Claude.
|
|
191
|
+
Register workspace directories. Workspace rules are compiled into a canonical `AGENTS.md` at the workspace root. Repos keep their own repo-specific `AGENTS.md`; Codex receives the workspace rules through generated, git-excluded `AGENTS.override.md` files, while OpenCode workspace configs reference the shared workspace file through `instructions`. Claude receives generated `CLAUDE.local.md` files and project MCP entries in `~/.claude.json["projects"][absolute_repo_path]["mcpServers"]`. Repo-local app config, skills, and agents are propagated for OpenCode, Cursor, Codex, and Claude.
|
|
192
192
|
|
|
193
|
-
|
|
193
|
+
Cursor propagation intentionally stays to repo-local MCP, skills, and agents; it does not copy the shared workspace `AGENTS.md` into child repos.
|
|
194
194
|
|
|
195
195
|
```bash
|
|
196
196
|
code-agnostic workspaces add --name myproject --path ~/code/myproject
|
|
@@ -69,10 +69,13 @@ def serialize_codex_agent(agent: Agent) -> str:
|
|
|
69
69
|
description = agent.metadata.description or agent.metadata.name or agent.name
|
|
70
70
|
|
|
71
71
|
doc = tomlkit.document()
|
|
72
|
-
doc.add("name", agent.metadata.name or agent.name)
|
|
73
|
-
doc.add("description", description)
|
|
72
|
+
doc.add("name", tomlkit.item(agent.metadata.name or agent.name))
|
|
73
|
+
doc.add("description", tomlkit.item(description))
|
|
74
74
|
if agent.metadata.nickname_candidates:
|
|
75
|
-
doc.add(
|
|
75
|
+
doc.add(
|
|
76
|
+
"nickname_candidates",
|
|
77
|
+
tomlkit.item(list(agent.metadata.nickname_candidates)),
|
|
78
|
+
)
|
|
76
79
|
model = agent.metadata.effective_value("codex", "model")
|
|
77
80
|
if model:
|
|
78
81
|
doc.add("model", model)
|
|
@@ -63,7 +63,7 @@ APP_CATALOG: dict[AppId, AppMetadata] = {
|
|
|
63
63
|
toggleable=True,
|
|
64
64
|
importable=True,
|
|
65
65
|
supports_import_agents=True,
|
|
66
|
-
supports_workspace_propagation=
|
|
66
|
+
supports_workspace_propagation=True,
|
|
67
67
|
project_dir_name=CURSOR_PROJECT_DIRNAME,
|
|
68
68
|
config_filename=CURSOR_CONFIG_FILENAME,
|
|
69
69
|
),
|
|
@@ -8,10 +8,6 @@ from code_agnostic.agents.parser import parse_agent
|
|
|
8
8
|
from code_agnostic.apps.app_id import AppId, app_label
|
|
9
9
|
from code_agnostic.apps.claude.config_repository import ClaudeConfigRepository
|
|
10
10
|
from code_agnostic.apps.claude.mapper import ClaudeMCPMapper
|
|
11
|
-
from code_agnostic.apps.common.compiled_planning import (
|
|
12
|
-
find_replaceable_symlink_ancestor,
|
|
13
|
-
plan_owned_compiled_text_action,
|
|
14
|
-
)
|
|
15
11
|
from code_agnostic.apps.common.framework import RegisteredAppConfigService
|
|
16
12
|
from code_agnostic.apps.common.interfaces.mapper import IAppMCPMapper
|
|
17
13
|
from code_agnostic.apps.common.interfaces.repositories import IAppConfigRepository
|
|
@@ -135,7 +131,7 @@ class ClaudeConfigService(RegisteredAppConfigService):
|
|
|
135
131
|
removable_links: list[Path],
|
|
136
132
|
) -> tuple[list[Action], list[Path], list[str]]:
|
|
137
133
|
compiler = ClaudeSkillCompiler()
|
|
138
|
-
return self.
|
|
134
|
+
return self._plan_compiled_text_actions(
|
|
139
135
|
sources=sources,
|
|
140
136
|
target_dir=target_dir,
|
|
141
137
|
scope=scope,
|
|
@@ -173,7 +169,7 @@ class ClaudeConfigService(RegisteredAppConfigService):
|
|
|
173
169
|
agent = parse_agent(source)
|
|
174
170
|
return claude_agent_target_path(target_dir, agent), compiler.compile(agent)
|
|
175
171
|
|
|
176
|
-
return self.
|
|
172
|
+
return self._plan_compiled_text_actions(
|
|
177
173
|
sources=sources,
|
|
178
174
|
target_dir=target_dir,
|
|
179
175
|
scope=scope,
|
|
@@ -186,63 +182,3 @@ class ClaudeConfigService(RegisteredAppConfigService):
|
|
|
186
182
|
update_detail="update compiled claude agent",
|
|
187
183
|
conflict_message="Claude agent sync skipped (conflict): {target}",
|
|
188
184
|
)
|
|
189
|
-
|
|
190
|
-
def _plan_owned_text_actions(
|
|
191
|
-
self,
|
|
192
|
-
*,
|
|
193
|
-
sources: list[Path],
|
|
194
|
-
target_dir: Path,
|
|
195
|
-
scope: str,
|
|
196
|
-
app: str,
|
|
197
|
-
managed_paths: list[Path],
|
|
198
|
-
removable_links: list[Path],
|
|
199
|
-
compile_source,
|
|
200
|
-
create_detail: str,
|
|
201
|
-
noop_detail: str,
|
|
202
|
-
update_detail: str,
|
|
203
|
-
conflict_message: str,
|
|
204
|
-
) -> tuple[list[Action], list[Path], list[str]]:
|
|
205
|
-
managed_path_set = {path.resolve(strict=False) for path in managed_paths}
|
|
206
|
-
removable_link_set = {path.resolve(strict=False) for path in removable_links}
|
|
207
|
-
actions: list[Action] = []
|
|
208
|
-
desired_paths: list[Path] = []
|
|
209
|
-
skipped: list[str] = []
|
|
210
|
-
scheduled_removals: set[Path] = set()
|
|
211
|
-
|
|
212
|
-
for source in sources:
|
|
213
|
-
target, payload = compile_source(source)
|
|
214
|
-
desired_paths.append(target)
|
|
215
|
-
replaceable_symlink = find_replaceable_symlink_ancestor(target, target_dir)
|
|
216
|
-
if (
|
|
217
|
-
replaceable_symlink is not None
|
|
218
|
-
and replaceable_symlink not in scheduled_removals
|
|
219
|
-
):
|
|
220
|
-
scheduled_removals.add(replaceable_symlink)
|
|
221
|
-
removable_link_set.add(replaceable_symlink.resolve(strict=False))
|
|
222
|
-
actions.append(
|
|
223
|
-
Action(
|
|
224
|
-
kind=ActionKind.REMOVE_SYMLINK,
|
|
225
|
-
path=replaceable_symlink,
|
|
226
|
-
status=ActionStatus.REMOVE,
|
|
227
|
-
detail=f"replace compiled {scope} symlink",
|
|
228
|
-
app=app,
|
|
229
|
-
scope=scope,
|
|
230
|
-
)
|
|
231
|
-
)
|
|
232
|
-
action = plan_owned_compiled_text_action(
|
|
233
|
-
target=target,
|
|
234
|
-
payload=payload,
|
|
235
|
-
managed_paths=managed_path_set,
|
|
236
|
-
removable_link_paths=removable_link_set,
|
|
237
|
-
managed_root=target_dir,
|
|
238
|
-
scope=scope,
|
|
239
|
-
app=app,
|
|
240
|
-
create_detail=create_detail,
|
|
241
|
-
noop_detail=noop_detail,
|
|
242
|
-
update_detail=update_detail,
|
|
243
|
-
)
|
|
244
|
-
actions.append(action)
|
|
245
|
-
if action.status == ActionStatus.CONFLICT:
|
|
246
|
-
skipped.append(conflict_message.format(target=target))
|
|
247
|
-
|
|
248
|
-
return actions, desired_paths, skipped
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from code_agnostic.generated_artifacts import (
|
|
4
|
+
ArtifactKind,
|
|
5
|
+
GeneratedArtifact,
|
|
6
|
+
OwnershipPolicy,
|
|
7
|
+
plan_generated_artifact,
|
|
8
|
+
)
|
|
9
|
+
from code_agnostic.models import Action
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def find_replaceable_symlink_ancestor(target: Path, managed_root: Path) -> Path | None:
|
|
13
|
+
current = target
|
|
14
|
+
while True:
|
|
15
|
+
if current.is_symlink() and (
|
|
16
|
+
current == managed_root or current.is_relative_to(managed_root)
|
|
17
|
+
):
|
|
18
|
+
return current
|
|
19
|
+
if current.parent == current:
|
|
20
|
+
return None
|
|
21
|
+
current = current.parent
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def plan_compiled_text_action(
|
|
25
|
+
*,
|
|
26
|
+
target: Path,
|
|
27
|
+
payload: str,
|
|
28
|
+
managed_paths: set[Path],
|
|
29
|
+
removable_link_paths: set[Path] | None = None,
|
|
30
|
+
managed_root: Path | None = None,
|
|
31
|
+
scope: str,
|
|
32
|
+
app: str,
|
|
33
|
+
create_detail: str,
|
|
34
|
+
noop_detail: str,
|
|
35
|
+
update_detail: str,
|
|
36
|
+
conflict_detail: str = "non-managed path exists",
|
|
37
|
+
) -> Action:
|
|
38
|
+
return _plan_text_action(
|
|
39
|
+
ownership=OwnershipPolicy.MANAGED_REPLACE,
|
|
40
|
+
target=target,
|
|
41
|
+
payload=payload,
|
|
42
|
+
managed_paths=managed_paths,
|
|
43
|
+
removable_link_paths=removable_link_paths,
|
|
44
|
+
managed_root=managed_root,
|
|
45
|
+
scope=scope,
|
|
46
|
+
app=app,
|
|
47
|
+
create_detail=create_detail,
|
|
48
|
+
noop_detail=noop_detail,
|
|
49
|
+
update_detail=update_detail,
|
|
50
|
+
conflict_detail=conflict_detail,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def plan_owned_compiled_text_action(
|
|
55
|
+
*,
|
|
56
|
+
target: Path,
|
|
57
|
+
payload: str,
|
|
58
|
+
managed_paths: set[Path],
|
|
59
|
+
removable_link_paths: set[Path] | None = None,
|
|
60
|
+
managed_root: Path | None = None,
|
|
61
|
+
scope: str,
|
|
62
|
+
app: str,
|
|
63
|
+
create_detail: str,
|
|
64
|
+
noop_detail: str,
|
|
65
|
+
update_detail: str,
|
|
66
|
+
conflict_detail: str = "non-managed path exists",
|
|
67
|
+
) -> Action:
|
|
68
|
+
return _plan_text_action(
|
|
69
|
+
ownership=OwnershipPolicy.OWNED_ONLY,
|
|
70
|
+
target=target,
|
|
71
|
+
payload=payload,
|
|
72
|
+
managed_paths=managed_paths,
|
|
73
|
+
removable_link_paths=removable_link_paths,
|
|
74
|
+
managed_root=managed_root,
|
|
75
|
+
scope=scope,
|
|
76
|
+
app=app,
|
|
77
|
+
create_detail=create_detail,
|
|
78
|
+
noop_detail=noop_detail,
|
|
79
|
+
update_detail=update_detail,
|
|
80
|
+
conflict_detail=conflict_detail,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _plan_text_action(
|
|
85
|
+
*,
|
|
86
|
+
ownership: OwnershipPolicy,
|
|
87
|
+
target: Path,
|
|
88
|
+
payload: str,
|
|
89
|
+
managed_paths: set[Path],
|
|
90
|
+
removable_link_paths: set[Path] | None,
|
|
91
|
+
managed_root: Path | None,
|
|
92
|
+
scope: str,
|
|
93
|
+
app: str,
|
|
94
|
+
create_detail: str,
|
|
95
|
+
noop_detail: str,
|
|
96
|
+
update_detail: str,
|
|
97
|
+
conflict_detail: str,
|
|
98
|
+
) -> Action:
|
|
99
|
+
return plan_generated_artifact(
|
|
100
|
+
GeneratedArtifact(
|
|
101
|
+
path=target,
|
|
102
|
+
kind=ArtifactKind.TEXT,
|
|
103
|
+
payload=payload,
|
|
104
|
+
ownership=ownership,
|
|
105
|
+
managed_root=managed_root,
|
|
106
|
+
scope=scope,
|
|
107
|
+
app=app,
|
|
108
|
+
create_detail=create_detail,
|
|
109
|
+
noop_detail=noop_detail,
|
|
110
|
+
update_detail=update_detail,
|
|
111
|
+
conflict_detail=conflict_detail,
|
|
112
|
+
),
|
|
113
|
+
managed_paths=managed_paths,
|
|
114
|
+
removable_link_paths=removable_link_paths,
|
|
115
|
+
)
|
{code_agnostic-0.3.7 → code_agnostic-0.3.8}/code_agnostic/apps/common/interfaces/repositories.py
RENAMED
|
@@ -20,6 +20,16 @@ class IAppConfigRepository(ABC):
|
|
|
20
20
|
def config_path(self) -> Path:
|
|
21
21
|
raise NotImplementedError
|
|
22
22
|
|
|
23
|
+
@property
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def skills_dir(self) -> Path:
|
|
26
|
+
raise NotImplementedError
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def agents_dir(self) -> Path:
|
|
31
|
+
raise NotImplementedError
|
|
32
|
+
|
|
23
33
|
@abstractmethod
|
|
24
34
|
def load_config(self) -> dict[str, Any]:
|
|
25
35
|
raise NotImplementedError
|
|
@@ -6,7 +6,6 @@ from typing import Any
|
|
|
6
6
|
from code_agnostic.apps.app_id import AppId, app_scope
|
|
7
7
|
from code_agnostic.apps.common.compiled_planning import (
|
|
8
8
|
find_replaceable_symlink_ancestor,
|
|
9
|
-
plan_compiled_text_action,
|
|
10
9
|
)
|
|
11
10
|
from code_agnostic.apps.common.interfaces.mapper import IAppMCPMapper
|
|
12
11
|
from code_agnostic.apps.common.interfaces.repositories import IAppConfigRepository
|
|
@@ -18,6 +17,12 @@ from code_agnostic.apps.common.symlink_planning import (
|
|
|
18
17
|
plan_stale_files_group,
|
|
19
18
|
plan_stale_group,
|
|
20
19
|
)
|
|
20
|
+
from code_agnostic.generated_artifacts import (
|
|
21
|
+
ArtifactKind,
|
|
22
|
+
GeneratedArtifact,
|
|
23
|
+
OwnershipPolicy,
|
|
24
|
+
plan_generated_artifact,
|
|
25
|
+
)
|
|
21
26
|
from code_agnostic.models import Action, ActionKind, ActionStatus, SyncPlan
|
|
22
27
|
|
|
23
28
|
|
|
@@ -92,7 +97,10 @@ class IAppConfigService(ABC):
|
|
|
92
97
|
raise NotImplementedError
|
|
93
98
|
|
|
94
99
|
def agent_action_removable_links(self, removable_links: list[Path]) -> list[Path]:
|
|
95
|
-
return
|
|
100
|
+
return removable_links
|
|
101
|
+
|
|
102
|
+
def compiled_resource_ownership_policy(self) -> OwnershipPolicy:
|
|
103
|
+
return OwnershipPolicy.OWNED_ONLY
|
|
96
104
|
|
|
97
105
|
@staticmethod
|
|
98
106
|
def _normalize_managed_group(value: Any) -> dict[str, Any]:
|
|
@@ -119,14 +127,18 @@ class IAppConfigService(ABC):
|
|
|
119
127
|
desired_paths: list[Path] = []
|
|
120
128
|
skipped: list[str] = []
|
|
121
129
|
scheduled_removals: set[Path] = set()
|
|
130
|
+
ownership = self.compiled_resource_ownership_policy()
|
|
122
131
|
|
|
123
132
|
for source in sources:
|
|
124
133
|
target, payload = compile_source(source)
|
|
125
134
|
desired_paths.append(target)
|
|
126
135
|
replaceable_symlink = find_replaceable_symlink_ancestor(target, target_dir)
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
136
|
+
can_replace_symlink = replaceable_symlink is not None and (
|
|
137
|
+
ownership != OwnershipPolicy.OWNED_ONLY
|
|
138
|
+
or replaceable_symlink.resolve(strict=False) in removable_link_set
|
|
139
|
+
)
|
|
140
|
+
if replaceable_symlink is not None and (
|
|
141
|
+
can_replace_symlink and replaceable_symlink not in scheduled_removals
|
|
130
142
|
):
|
|
131
143
|
scheduled_removals.add(replaceable_symlink)
|
|
132
144
|
removable_link_set.add(replaceable_symlink.resolve(strict=False))
|
|
@@ -140,17 +152,21 @@ class IAppConfigService(ABC):
|
|
|
140
152
|
scope=scope,
|
|
141
153
|
)
|
|
142
154
|
)
|
|
143
|
-
action =
|
|
144
|
-
|
|
145
|
-
|
|
155
|
+
action = plan_generated_artifact(
|
|
156
|
+
GeneratedArtifact(
|
|
157
|
+
path=target,
|
|
158
|
+
kind=ArtifactKind.TEXT,
|
|
159
|
+
payload=payload,
|
|
160
|
+
ownership=ownership,
|
|
161
|
+
managed_root=target_dir,
|
|
162
|
+
scope=scope,
|
|
163
|
+
app=app,
|
|
164
|
+
create_detail=create_detail,
|
|
165
|
+
noop_detail=noop_detail,
|
|
166
|
+
update_detail=update_detail,
|
|
167
|
+
),
|
|
146
168
|
managed_paths=managed_path_set,
|
|
147
169
|
removable_link_paths=removable_link_set,
|
|
148
|
-
managed_root=target_dir,
|
|
149
|
-
scope=scope,
|
|
150
|
-
app=app,
|
|
151
|
-
create_detail=create_detail,
|
|
152
|
-
noop_detail=noop_detail,
|
|
153
|
-
update_detail=update_detail,
|
|
154
170
|
)
|
|
155
171
|
actions.append(action)
|
|
156
172
|
if action.status == ActionStatus.CONFLICT:
|
|
@@ -119,6 +119,8 @@ class OpenCodeConfigService(RegisteredAppConfigService):
|
|
|
119
119
|
from code_agnostic.errors import InvalidJsonFormatError
|
|
120
120
|
from code_agnostic.utils import read_json_safe
|
|
121
121
|
|
|
122
|
+
if self._base_config_path is None:
|
|
123
|
+
return {}
|
|
122
124
|
if not self._base_config_path.exists():
|
|
123
125
|
return {}
|
|
124
126
|
payload, error = read_json_safe(self._base_config_path)
|
|
@@ -114,12 +114,17 @@ def workspaces_git_exclude(obj: dict[str, str], workspace: str | None) -> None:
|
|
|
114
114
|
workspace_path = Path(item["path"])
|
|
115
115
|
if not workspace_path.exists() or not workspace_path.is_dir():
|
|
116
116
|
continue
|
|
117
|
-
entries = exclude_service.compute_entries(item["name"], enabled_apps)
|
|
118
117
|
repos = workspace_service.discover_git_repos(workspace_path)
|
|
119
118
|
for repo in repos:
|
|
120
119
|
git_dir = workspace_service.resolve_git_dir(repo)
|
|
121
120
|
if git_dir is None:
|
|
122
121
|
continue
|
|
122
|
+
entries = exclude_service.compute_entries_for_repo(
|
|
123
|
+
item["name"],
|
|
124
|
+
enabled_apps,
|
|
125
|
+
workspace_path=workspace_path,
|
|
126
|
+
repo_path=repo,
|
|
127
|
+
)
|
|
123
128
|
exclude_path = git_dir / "info" / "exclude"
|
|
124
129
|
added, changed = ensure_exclude_entries(exclude_path, entries)
|
|
125
130
|
processed += 1
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from code_agnostic.models import Action, ActionKind, ActionStatus
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ArtifactKind(str, Enum):
|
|
12
|
+
TEXT = "text"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class OwnershipPolicy(str, Enum):
|
|
16
|
+
OWNED_ONLY = "owned_only"
|
|
17
|
+
MANAGED_REPLACE = "managed_replace"
|
|
18
|
+
MERGE_NATIVE_CONFIG = "merge_native_config"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class GeneratedArtifact:
|
|
23
|
+
path: Path
|
|
24
|
+
kind: ArtifactKind
|
|
25
|
+
payload: Any
|
|
26
|
+
ownership: OwnershipPolicy
|
|
27
|
+
scope: str
|
|
28
|
+
app: str
|
|
29
|
+
create_detail: str
|
|
30
|
+
noop_detail: str
|
|
31
|
+
update_detail: str
|
|
32
|
+
conflict_detail: str = "non-managed path exists"
|
|
33
|
+
managed_root: Path | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def plan_generated_artifact(
|
|
37
|
+
artifact: GeneratedArtifact,
|
|
38
|
+
*,
|
|
39
|
+
managed_paths: set[Path],
|
|
40
|
+
removable_link_paths: set[Path] | None = None,
|
|
41
|
+
) -> Action:
|
|
42
|
+
if artifact.kind != ArtifactKind.TEXT:
|
|
43
|
+
raise NotImplementedError(
|
|
44
|
+
f"Unsupported generated artifact kind: {artifact.kind}"
|
|
45
|
+
)
|
|
46
|
+
if not isinstance(artifact.payload, str):
|
|
47
|
+
raise TypeError("Text generated artifacts require a string payload")
|
|
48
|
+
|
|
49
|
+
managed_path_set = {path.resolve(strict=False) for path in managed_paths}
|
|
50
|
+
removable = {path.resolve(strict=False) for path in (removable_link_paths or set())}
|
|
51
|
+
has_symlink_ancestor, is_removable_ancestor = _symlink_ancestor_state(
|
|
52
|
+
artifact.path, removable, artifact.managed_root
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
if artifact.ownership == OwnershipPolicy.OWNED_ONLY:
|
|
56
|
+
target_key = artifact.path.resolve(strict=False)
|
|
57
|
+
is_managed_target = target_key in managed_path_set or _is_under_any(
|
|
58
|
+
target_key, removable
|
|
59
|
+
)
|
|
60
|
+
if not is_managed_target and not is_removable_ancestor:
|
|
61
|
+
if (
|
|
62
|
+
not artifact.path.exists()
|
|
63
|
+
and not artifact.path.is_symlink()
|
|
64
|
+
and not has_symlink_ancestor
|
|
65
|
+
):
|
|
66
|
+
return _action(artifact, ActionStatus.CREATE, artifact.create_detail)
|
|
67
|
+
return _action(artifact, ActionStatus.CONFLICT, artifact.conflict_detail)
|
|
68
|
+
|
|
69
|
+
return _plan_replace_text_artifact(
|
|
70
|
+
artifact,
|
|
71
|
+
has_symlink_ancestor=has_symlink_ancestor,
|
|
72
|
+
is_removable_ancestor=is_removable_ancestor,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _is_under_any(path: Path, ancestors: set[Path]) -> bool:
|
|
77
|
+
return any(
|
|
78
|
+
path == ancestor or path.is_relative_to(ancestor) for ancestor in ancestors
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _symlink_ancestor_state(
|
|
83
|
+
target: Path, removable_link_paths: set[Path], managed_root: Path | None
|
|
84
|
+
) -> tuple[bool, bool]:
|
|
85
|
+
current = target
|
|
86
|
+
found_symlink = False
|
|
87
|
+
while True:
|
|
88
|
+
in_managed_root = managed_root is None or (
|
|
89
|
+
current == managed_root or current.is_relative_to(managed_root)
|
|
90
|
+
)
|
|
91
|
+
if current.is_symlink() and in_managed_root:
|
|
92
|
+
found_symlink = True
|
|
93
|
+
if current.resolve(strict=False) in removable_link_paths:
|
|
94
|
+
return True, True
|
|
95
|
+
if current.parent == current:
|
|
96
|
+
return found_symlink, False
|
|
97
|
+
current = current.parent
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _plan_replace_text_artifact(
|
|
101
|
+
artifact: GeneratedArtifact,
|
|
102
|
+
*,
|
|
103
|
+
has_symlink_ancestor: bool,
|
|
104
|
+
is_removable_ancestor: bool,
|
|
105
|
+
) -> Action:
|
|
106
|
+
if has_symlink_ancestor and not is_removable_ancestor:
|
|
107
|
+
return _action(artifact, ActionStatus.CONFLICT, artifact.conflict_detail)
|
|
108
|
+
|
|
109
|
+
if not artifact.path.exists() and not artifact.path.is_symlink():
|
|
110
|
+
return _action(artifact, ActionStatus.CREATE, artifact.create_detail)
|
|
111
|
+
|
|
112
|
+
if artifact.path.is_file():
|
|
113
|
+
existing = artifact.path.read_text(encoding="utf-8")
|
|
114
|
+
if existing == artifact.payload:
|
|
115
|
+
return _action(artifact, ActionStatus.NOOP, artifact.noop_detail)
|
|
116
|
+
return _action(artifact, ActionStatus.UPDATE, artifact.update_detail)
|
|
117
|
+
|
|
118
|
+
return _action(artifact, ActionStatus.CONFLICT, artifact.conflict_detail)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _action(
|
|
122
|
+
artifact: GeneratedArtifact,
|
|
123
|
+
status: ActionStatus,
|
|
124
|
+
detail: str,
|
|
125
|
+
) -> Action:
|
|
126
|
+
return Action(
|
|
127
|
+
kind=ActionKind.WRITE_TEXT,
|
|
128
|
+
path=artifact.path,
|
|
129
|
+
status=status,
|
|
130
|
+
detail=detail,
|
|
131
|
+
payload=artifact.payload,
|
|
132
|
+
app=artifact.app,
|
|
133
|
+
scope=artifact.scope,
|
|
134
|
+
)
|