code-agnostic 0.3.12__tar.gz → 0.3.13__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/PKG-INFO +23 -15
  2. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/README.md +22 -14
  3. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/__init__.py +1 -1
  4. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/__main__.py +2 -0
  5. code_agnostic-0.3.13/code_agnostic/cli/commands/projects.py +78 -0
  6. code_agnostic-0.3.13/code_agnostic/cli/commands/skills.py +192 -0
  7. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/cli/commands/status.py +20 -2
  8. code_agnostic-0.3.13/code_agnostic/core/project_repository.py +10 -0
  9. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/core/repository.py +90 -0
  10. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/executor.py +122 -41
  11. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/models.py +15 -0
  12. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/planner.py +124 -1
  13. code_agnostic-0.3.13/code_agnostic/project_artifacts.py +39 -0
  14. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/status.py +98 -1
  15. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/tui/renderers.py +62 -30
  16. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/tui/tables.py +39 -3
  17. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic.egg-info/PKG-INFO +23 -15
  18. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic.egg-info/SOURCES.txt +5 -0
  19. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/pyproject.toml +1 -1
  20. code_agnostic-0.3.13/tests/test_cli_projects.py +227 -0
  21. code_agnostic-0.3.13/tests/test_cli_skills.py +334 -0
  22. code_agnostic-0.3.13/tests/test_project_config_sync.py +242 -0
  23. code_agnostic-0.3.12/code_agnostic/cli/commands/skills.py +0 -77
  24. code_agnostic-0.3.12/tests/test_cli_skills.py +0 -133
  25. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/LICENSE +0 -0
  26. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/agents/__init__.py +0 -0
  27. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/agents/claude.py +0 -0
  28. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/agents/codex.py +0 -0
  29. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/agents/compilers.py +0 -0
  30. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/agents/models.py +0 -0
  31. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/agents/opencode.py +0 -0
  32. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/agents/parser.py +0 -0
  33. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/__init__.py +0 -0
  34. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/app_id.py +0 -0
  35. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/apps_service.py +0 -0
  36. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/claude/__init__.py +0 -0
  37. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/claude/config_repository.py +0 -0
  38. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/claude/mapper.py +0 -0
  39. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/claude/service.py +0 -0
  40. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/codex/__init__.py +0 -0
  41. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/codex/config_repository.py +0 -0
  42. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/codex/mapper.py +0 -0
  43. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/codex/schema.json +0 -0
  44. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/codex/schema_repository.py +0 -0
  45. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/codex/service.py +0 -0
  46. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/common/__init__.py +0 -0
  47. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/common/compiled_planning.py +0 -0
  48. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/common/framework.py +0 -0
  49. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/common/interfaces/__init__.py +0 -0
  50. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/common/interfaces/mapper.py +0 -0
  51. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/common/interfaces/repositories.py +0 -0
  52. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/common/interfaces/service.py +0 -0
  53. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/common/loader.py +0 -0
  54. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/common/models.py +0 -0
  55. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/common/schema.py +0 -0
  56. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/common/symlink_planning.py +0 -0
  57. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/common/utils.py +0 -0
  58. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/cursor/__init__.py +0 -0
  59. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/cursor/config_repository.py +0 -0
  60. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/cursor/mapper.py +0 -0
  61. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/cursor/schema.json +0 -0
  62. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/cursor/schema_repository.py +0 -0
  63. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/cursor/service.py +0 -0
  64. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/opencode/__init__.py +0 -0
  65. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/opencode/config_repository.py +0 -0
  66. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/opencode/mapper.py +0 -0
  67. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/opencode/schema.json +0 -0
  68. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/opencode/schema_repository.py +0 -0
  69. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/opencode/service.py +0 -0
  70. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/cli/__init__.py +0 -0
  71. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/cli/aliases.py +0 -0
  72. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/cli/commands/__init__.py +0 -0
  73. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/cli/commands/agents.py +0 -0
  74. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/cli/commands/apply.py +0 -0
  75. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/cli/commands/apps.py +0 -0
  76. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/cli/commands/explain_lossiness.py +0 -0
  77. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/cli/commands/import_.py +0 -0
  78. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/cli/commands/mcp.py +0 -0
  79. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/cli/commands/plan.py +0 -0
  80. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/cli/commands/restore.py +0 -0
  81. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/cli/commands/rules.py +0 -0
  82. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/cli/commands/validate.py +0 -0
  83. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/cli/commands/workspaces.py +0 -0
  84. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/cli/helpers.py +0 -0
  85. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/cli/options.py +0 -0
  86. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/constants.py +0 -0
  87. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/core/__init__.py +0 -0
  88. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/core/workspace_repository.py +0 -0
  89. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/errors.py +0 -0
  90. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/generated_artifacts.py +0 -0
  91. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/git_exclude_service.py +0 -0
  92. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/imports/__init__.py +0 -0
  93. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/imports/adapters.py +0 -0
  94. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/imports/filesystem.py +0 -0
  95. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/imports/models.py +0 -0
  96. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/imports/service.py +0 -0
  97. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/lossiness.py +0 -0
  98. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/mcp_service.py +0 -0
  99. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/rules/__init__.py +0 -0
  100. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/rules/compilers.py +0 -0
  101. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/rules/models.py +0 -0
  102. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/rules/parser.py +0 -0
  103. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/rules/repository.py +0 -0
  104. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/skills/__init__.py +0 -0
  105. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/skills/compilers.py +0 -0
  106. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/skills/models.py +0 -0
  107. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/skills/parser.py +0 -0
  108. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/spec/__init__.py +0 -0
  109. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/spec/loaders.py +0 -0
  110. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/spec/schemas/agent.v1.schema.json +0 -0
  111. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/spec/schemas/mcp.base.schema.json +0 -0
  112. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/spec/schemas/mcp.v1.schema.json +0 -0
  113. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/spec/schemas/rule.v1.schema.json +0 -0
  114. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/spec/schemas/skill.v1.schema.json +0 -0
  115. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/tui/__init__.py +0 -0
  116. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/tui/enums.py +0 -0
  117. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/tui/import_selector.py +0 -0
  118. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/tui/sections.py +0 -0
  119. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/utils.py +0 -0
  120. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/validation.py +0 -0
  121. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/workspace_artifacts.py +0 -0
  122. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/workspaces.py +0 -0
  123. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic.egg-info/dependency_links.txt +0 -0
  124. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic.egg-info/entry_points.txt +0 -0
  125. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic.egg-info/requires.txt +0 -0
  126. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic.egg-info/top_level.txt +0 -0
  127. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/setup.cfg +0 -0
  128. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_cli_agents.py +0 -0
  129. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_cli_aliases.py +0 -0
  130. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_cli_apply_apps.py +0 -0
  131. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_cli_apply_codex.py +0 -0
  132. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_cli_apply_cursor.py +0 -0
  133. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_cli_apply_target.py +0 -0
  134. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_cli_apps.py +0 -0
  135. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_cli_explain_lossiness.py +0 -0
  136. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_cli_flags.py +0 -0
  137. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_cli_git_exclude.py +0 -0
  138. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_cli_import.py +0 -0
  139. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_cli_import_interactive.py +0 -0
  140. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_cli_mcp.py +0 -0
  141. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_cli_module_organization.py +0 -0
  142. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_cli_plan.py +0 -0
  143. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_cli_restore.py +0 -0
  144. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_cli_rules.py +0 -0
  145. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_cli_status.py +0 -0
  146. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_cli_validate.py +0 -0
  147. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_cli_workspace_resolution.py +0 -0
  148. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_cli_workspaces.py +0 -0
  149. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_common_mcp_to_dto.py +0 -0
  150. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_common_repository.py +0 -0
  151. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_compiled_planning.py +0 -0
  152. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_dto_to_common_mcp.py +0 -0
  153. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_git_exclude_service.py +0 -0
  154. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_mcp_service.py +0 -0
  155. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_planner_executor.py +0 -0
  156. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_planner_rules.py +0 -0
  157. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_symlink_planning.py +0 -0
  158. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_sync_plan_model.py +0 -0
  159. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_transactional_executor.py +0 -0
  160. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_utils.py +0 -0
  161. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_version.py +0 -0
  162. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_workspace_config_sync.py +0 -0
  163. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_workspace_repo_status.py +0 -0
  164. {code_agnostic-0.3.12 → code_agnostic-0.3.13}/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.12
3
+ Version: 0.3.13
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
@@ -66,19 +66,19 @@ Today the implementation is still mixed: some assets are compiled and some are s
66
66
 
67
67
  ## Scope model
68
68
 
69
- `code-agnostic` has two managed source scopes today:
69
+ `code-agnostic` has three managed source scopes:
70
70
 
71
71
  - global source config under `~/.config/code-agnostic/`, synced to enabled
72
72
  user-level app config;
73
73
  - workspace source config under `~/.config/code-agnostic/workspaces/<name>/`,
74
- propagated into repos inside a registered workspace.
74
+ propagated into repos inside a registered workspace;
75
+ - project source config under `~/.config/code-agnostic/projects/<name>/`,
76
+ synced to exactly one registered project directory.
75
77
 
76
78
  Workspace sync may generate repo-local outputs, but those outputs are not
77
- source. Project-local skill folders that users create directly inside a repo,
78
- such as `.agents/skills` or `.opencode/skills`, are app-native inputs but are
79
- not managed as source by `code-agnostic` yet. First-class project-scoped
80
- installs are planned so a single registered project can have managed local
81
- source config without bypassing the hub.
79
+ source. Project-local skill folders generated inside a repo, such as
80
+ `.agents/skills` or `.opencode/skills`, are also generated outputs; install
81
+ skills into managed project source first, then run `plan` / `apply`.
82
82
 
83
83
  ## Install
84
84
 
@@ -230,19 +230,27 @@ code-agnostic skills list
230
230
  code-agnostic agents list
231
231
  ```
232
232
 
233
- Manual skill install today:
233
+ Install a local skill directory into managed source:
234
234
 
235
235
  ```bash
236
- mkdir -p ~/.config/code-agnostic/skills
237
- cp -R ./my-skill ~/.config/code-agnostic/skills/my-skill
236
+ code-agnostic skills install ./my-skill --global
237
+ code-agnostic skills install ./my-skill --workspace myworkspace
238
+ code-agnostic projects add --name myproject --path .
239
+ code-agnostic skills install ./my-skill --project myproject
238
240
  code-agnostic plan
239
241
  code-agnostic apply
240
242
  ```
241
243
 
242
- There is no `skills install` command yet; copy skills into managed source first,
243
- then use the normal `plan` / `apply` workflow.
244
-
245
- Global skills live under `~/.config/code-agnostic/skills`. Workspace-local skills live under `~/.config/code-agnostic/workspaces/<name>/skills` and can be inspected with `code-agnostic skills list -w <name>`. Codex generated skill outputs are written to `~/.agents/skills`, while Codex agents and config remain under `CODEX_HOME` when set, defaulting to `~/.codex`. Claude Code generated skills and agents are written under `~/.claude/skills` and `~/.claude/agents`, with workspace copies under repo-local `.claude/skills` and `.claude/agents`.
244
+ Global skills live under `~/.config/code-agnostic/skills`. Workspace-local
245
+ skills live under `~/.config/code-agnostic/workspaces/<name>/skills` and can be
246
+ inspected with `code-agnostic skills list -w <name>`. Project-local skills live
247
+ under `~/.config/code-agnostic/projects/<name>/skills` and are generated into
248
+ the registered project directory by `plan` / `apply`. Codex generated skill
249
+ outputs are written to `~/.agents/skills`, while Codex agents and config remain
250
+ under `CODEX_HOME` when set, defaulting to `~/.codex`. Claude Code generated
251
+ skills and agents are written under `~/.claude/skills` and `~/.claude/agents`,
252
+ with workspace/project copies under repo-local `.claude/skills` and
253
+ `.claude/agents`.
246
254
 
247
255
  Project-local skills are not first-class source inputs in `code-agnostic` yet. If a target app discovers repo-local skill folders such as `.agents/skills`, `.opencode/skills`, or user-created `.claude/skills`, treat those as unmanaged app inputs. Workspace sync writes only the exact generated paths recorded in `.sync-state.json`.
248
256
 
@@ -41,19 +41,19 @@ Today the implementation is still mixed: some assets are compiled and some are s
41
41
 
42
42
  ## Scope model
43
43
 
44
- `code-agnostic` has two managed source scopes today:
44
+ `code-agnostic` has three managed source scopes:
45
45
 
46
46
  - global source config under `~/.config/code-agnostic/`, synced to enabled
47
47
  user-level app config;
48
48
  - workspace source config under `~/.config/code-agnostic/workspaces/<name>/`,
49
- propagated into repos inside a registered workspace.
49
+ propagated into repos inside a registered workspace;
50
+ - project source config under `~/.config/code-agnostic/projects/<name>/`,
51
+ synced to exactly one registered project directory.
50
52
 
51
53
  Workspace sync may generate repo-local outputs, but those outputs are not
52
- source. Project-local skill folders that users create directly inside a repo,
53
- such as `.agents/skills` or `.opencode/skills`, are app-native inputs but are
54
- not managed as source by `code-agnostic` yet. First-class project-scoped
55
- installs are planned so a single registered project can have managed local
56
- source config without bypassing the hub.
54
+ source. Project-local skill folders generated inside a repo, such as
55
+ `.agents/skills` or `.opencode/skills`, are also generated outputs; install
56
+ skills into managed project source first, then run `plan` / `apply`.
57
57
 
58
58
  ## Install
59
59
 
@@ -205,19 +205,27 @@ code-agnostic skills list
205
205
  code-agnostic agents list
206
206
  ```
207
207
 
208
- Manual skill install today:
208
+ Install a local skill directory into managed source:
209
209
 
210
210
  ```bash
211
- mkdir -p ~/.config/code-agnostic/skills
212
- cp -R ./my-skill ~/.config/code-agnostic/skills/my-skill
211
+ code-agnostic skills install ./my-skill --global
212
+ code-agnostic skills install ./my-skill --workspace myworkspace
213
+ code-agnostic projects add --name myproject --path .
214
+ code-agnostic skills install ./my-skill --project myproject
213
215
  code-agnostic plan
214
216
  code-agnostic apply
215
217
  ```
216
218
 
217
- There is no `skills install` command yet; copy skills into managed source first,
218
- then use the normal `plan` / `apply` workflow.
219
-
220
- Global skills live under `~/.config/code-agnostic/skills`. Workspace-local skills live under `~/.config/code-agnostic/workspaces/<name>/skills` and can be inspected with `code-agnostic skills list -w <name>`. Codex generated skill outputs are written to `~/.agents/skills`, while Codex agents and config remain under `CODEX_HOME` when set, defaulting to `~/.codex`. Claude Code generated skills and agents are written under `~/.claude/skills` and `~/.claude/agents`, with workspace copies under repo-local `.claude/skills` and `.claude/agents`.
219
+ Global skills live under `~/.config/code-agnostic/skills`. Workspace-local
220
+ skills live under `~/.config/code-agnostic/workspaces/<name>/skills` and can be
221
+ inspected with `code-agnostic skills list -w <name>`. Project-local skills live
222
+ under `~/.config/code-agnostic/projects/<name>/skills` and are generated into
223
+ the registered project directory by `plan` / `apply`. Codex generated skill
224
+ outputs are written to `~/.agents/skills`, while Codex agents and config remain
225
+ under `CODEX_HOME` when set, defaulting to `~/.codex`. Claude Code generated
226
+ skills and agents are written under `~/.claude/skills` and `~/.claude/agents`,
227
+ with workspace/project copies under repo-local `.claude/skills` and
228
+ `.claude/agents`.
221
229
 
222
230
  Project-local skills are not first-class source inputs in `code-agnostic` yet. If a target app discovers repo-local skill folders such as `.agents/skills`, `.opencode/skills`, or user-created `.claude/skills`, treat those as unmanaged app inputs. Workspace sync writes only the exact generated paths recorded in `.sync-state.json`.
223
231
 
@@ -1,3 +1,3 @@
1
1
  __all__ = ["__version__"]
2
2
 
3
- __version__ = "0.3.12"
3
+ __version__ = "0.3.13"
@@ -10,6 +10,7 @@ from code_agnostic.cli.commands.explain_lossiness import explain_lossiness
10
10
  from code_agnostic.cli.commands.import_ import import_group
11
11
  from code_agnostic.cli.commands.mcp import mcp
12
12
  from code_agnostic.cli.commands.plan import plan
13
+ from code_agnostic.cli.commands.projects import projects
13
14
  from code_agnostic.cli.commands.restore import restore
14
15
  from code_agnostic.cli.commands.rules import rules
15
16
  from code_agnostic.cli.commands.skills import skills
@@ -40,6 +41,7 @@ cli.add_command(explain_lossiness)
40
41
  # Register command groups
41
42
  cli.add_command(apps)
42
43
  cli.add_command(workspaces)
44
+ cli.add_command(projects)
43
45
  cli.add_command(rules)
44
46
  cli.add_command(skills)
45
47
  cli.add_command(agents_group)
@@ -0,0 +1,78 @@
1
+ """Projects group commands."""
2
+
3
+ from pathlib import Path
4
+
5
+ import click
6
+
7
+ from code_agnostic.core.project_repository import ProjectConfigRepository
8
+ from code_agnostic.core.repository import CoreRepository
9
+ from code_agnostic.errors import SyncAppError
10
+
11
+
12
+ @click.group(help="Manage project roots for project-local source config.")
13
+ def projects() -> None:
14
+ pass
15
+
16
+
17
+ @projects.command("add", help="Add a project by name and path.")
18
+ @click.option("--name", required=True, help="Project name.")
19
+ @click.option(
20
+ "--path",
21
+ required=True,
22
+ type=click.Path(path_type=Path),
23
+ help="Project root path.",
24
+ )
25
+ @click.pass_obj
26
+ def projects_add(obj: dict[str, str], name: str, path: Path) -> None:
27
+ core = CoreRepository()
28
+ try:
29
+ core.add_project(name, path)
30
+ except (ValueError, SyncAppError) as exc:
31
+ raise click.ClickException(str(exc))
32
+ click.echo(f"Project added: {name.strip()}")
33
+ click.echo(str(path.expanduser().resolve()))
34
+
35
+
36
+ @projects.command("remove", help="Unregister a project by name.")
37
+ @click.option("--name", required=True, help="Project name to unregister.")
38
+ @click.pass_obj
39
+ def projects_remove(obj: dict[str, str], name: str) -> None:
40
+ core = CoreRepository()
41
+ try:
42
+ removed = core.remove_project(name)
43
+ except SyncAppError as exc:
44
+ raise click.ClickException(str(exc))
45
+ if not removed:
46
+ raise click.ClickException(f"Project not found: {name}")
47
+ click.echo(f"Project unregistered: {name}")
48
+
49
+
50
+ @projects.command("list", help="List configured projects.")
51
+ @click.pass_obj
52
+ def projects_list(obj: dict[str, str]) -> None:
53
+ core = CoreRepository()
54
+ try:
55
+ items = core.load_projects()
56
+ except SyncAppError as exc:
57
+ raise click.ClickException(str(exc))
58
+
59
+ if not items:
60
+ click.echo("No projects configured.")
61
+ click.echo("code-agnostic projects add --name <name> --path <path>")
62
+ return
63
+
64
+ for item in items:
65
+ project_source = ProjectConfigRepository(
66
+ root=core.project_config_dir(item["name"])
67
+ )
68
+ markers = []
69
+ if project_source.has_mcp():
70
+ markers.append("mcp")
71
+ if project_source.has_rules():
72
+ markers.append("rules")
73
+ if project_source.has_skills():
74
+ markers.append("skills")
75
+ if project_source.has_agents():
76
+ markers.append("agents")
77
+ suffix = f" [{', '.join(markers)}]" if markers else ""
78
+ click.echo(f"{item['name']}: {item['path']}{suffix}")
@@ -0,0 +1,192 @@
1
+ """Skills group commands."""
2
+
3
+ from pathlib import Path
4
+ import shutil
5
+
6
+ import click
7
+ from rich.console import Console
8
+
9
+ from code_agnostic.cli.helpers import validate_resource_name, workspace_config_root
10
+ from code_agnostic.cli.options import workspace_option
11
+ from code_agnostic.core.repository import CoreRepository
12
+ from code_agnostic.errors import SyncAppError
13
+ from code_agnostic.tui import SyncConsoleUI
14
+ from code_agnostic.utils import compact_home_path
15
+
16
+
17
+ def _is_skill_source(path: Path) -> bool:
18
+ return path.is_dir() and (
19
+ (path / "SKILL.md").exists()
20
+ or ((path / "meta.yaml").exists() and (path / "prompt.md").exists())
21
+ )
22
+
23
+
24
+ def _entries_containing_cwd(
25
+ entries: list[dict[str, str]], cwd: Path
26
+ ) -> list[dict[str, str]]:
27
+ matches = []
28
+ for entry in entries:
29
+ root = Path(entry["path"]).expanduser().resolve()
30
+ if cwd == root or cwd.is_relative_to(root):
31
+ matches.append(entry)
32
+ return matches
33
+
34
+
35
+ def _project_entries_by_name(core: CoreRepository) -> dict[str, dict[str, str]]:
36
+ return {item["name"]: item for item in core.load_projects()}
37
+
38
+
39
+ def _install_root(
40
+ core: CoreRepository,
41
+ *,
42
+ global_scope: bool,
43
+ workspace: str | None,
44
+ project: str | None,
45
+ ) -> tuple[Path, str, str | None]:
46
+ explicit_count = sum(
47
+ [global_scope, workspace is not None, project is not None],
48
+ )
49
+ if explicit_count > 1:
50
+ raise click.ClickException(
51
+ "Choose only one scope: --global, --workspace, or --project."
52
+ )
53
+
54
+ try:
55
+ if global_scope:
56
+ return core.root, "global", None
57
+ if workspace is not None:
58
+ return (
59
+ workspace_config_root(core, workspace),
60
+ f"workspace:{workspace}",
61
+ None,
62
+ )
63
+ if project is not None:
64
+ projects = _project_entries_by_name(core)
65
+ if project not in projects:
66
+ raise click.ClickException(f"Project not found: {project}")
67
+ return core.project_config_dir(project), f"project:{project}", None
68
+
69
+ cwd = Path.cwd().resolve()
70
+ project_matches = _entries_containing_cwd(core.load_projects(), cwd)
71
+ if len(project_matches) == 1:
72
+ name = project_matches[0]["name"]
73
+ return core.project_config_dir(name), f"project:{name}", None
74
+
75
+ workspace_matches = _entries_containing_cwd(core.load_workspaces(), cwd)
76
+ if len(project_matches) == 0 and len(workspace_matches) == 1:
77
+ name = workspace_matches[0]["name"]
78
+ return core.workspace_config_dir(name), f"workspace:{name}", None
79
+ except SyncAppError as exc:
80
+ raise click.ClickException(str(exc)) from exc
81
+
82
+ raise click.ClickException(
83
+ "No unique project/workspace scope detected. Use --global, --project, "
84
+ "or --workspace."
85
+ )
86
+
87
+
88
+ @click.group(
89
+ help=(
90
+ "Manage source skill definitions. Commands use global source by default; "
91
+ "pass -w/--workspace for workspace source."
92
+ )
93
+ )
94
+ def skills() -> None:
95
+ pass
96
+
97
+
98
+ @skills.command("install", help="Install a local skill directory into source config.")
99
+ @click.argument("source", type=click.Path(path_type=Path))
100
+ @click.option("--global", "global_scope", is_flag=True, help="Install globally.")
101
+ @workspace_option()
102
+ @click.option("--project", help="Install into a registered project source.")
103
+ @click.pass_obj
104
+ def skills_install(
105
+ obj: dict[str, str],
106
+ source: Path,
107
+ global_scope: bool,
108
+ workspace: str | None,
109
+ project: str | None,
110
+ ) -> None:
111
+ source = source.expanduser().resolve()
112
+ if not _is_skill_source(source):
113
+ raise click.ClickException(
114
+ "Invalid skill source: expected a directory containing SKILL.md "
115
+ "or meta.yaml and prompt.md."
116
+ )
117
+
118
+ name = source.name
119
+ validate_resource_name(name, "skill")
120
+
121
+ core = CoreRepository()
122
+ root, scope, scope_note = _install_root(
123
+ core,
124
+ global_scope=global_scope,
125
+ workspace=workspace,
126
+ project=project,
127
+ )
128
+ destination = root / "skills" / name
129
+ if destination.exists():
130
+ raise click.ClickException(f"Skill already exists: {scope}:{name}")
131
+
132
+ destination.parent.mkdir(parents=True, exist_ok=True)
133
+ shutil.copytree(source, destination)
134
+ if scope_note is not None:
135
+ click.echo(scope_note)
136
+ click.echo(f"Installed {scope} skill: {name}")
137
+ click.echo(f"Source: {compact_home_path(source)}")
138
+ click.echo(f"Destination: {compact_home_path(destination)}")
139
+
140
+
141
+ @skills.command("list", help="List global skills, or workspace skills with -w.")
142
+ @workspace_option()
143
+ @click.pass_obj
144
+ def skills_list(obj: dict[str, str], workspace: str | None) -> None:
145
+ ui = SyncConsoleUI(Console())
146
+ core = CoreRepository()
147
+ root = workspace_config_root(core, workspace)
148
+ skill_sources = CoreRepository(root).list_skill_sources()
149
+ scope = f"workspace:{workspace}" if workspace else "global"
150
+ rows = [
151
+ [
152
+ source.name,
153
+ scope,
154
+ "bundle" if (source / "meta.yaml").exists() else "legacy",
155
+ compact_home_path(source),
156
+ ]
157
+ for source in skill_sources
158
+ ]
159
+ skill_dir = compact_home_path(root / "skills")
160
+ scope_message = (
161
+ f"No workspace skills configured for {workspace}"
162
+ if workspace
163
+ else "No global skills configured"
164
+ )
165
+ empty_message = (
166
+ f"{scope_message} in {skill_dir}.\n"
167
+ f"- Copy a skill into {skill_dir}/<name>\n"
168
+ "- code-agnostic plan\n"
169
+ "- code-agnostic apply"
170
+ )
171
+ ui.render_list(
172
+ "skills",
173
+ ["Skill", "Scope", "Format", "Source"],
174
+ rows,
175
+ empty_message,
176
+ )
177
+
178
+
179
+ @skills.command("remove", help="Remove a global skill, or a workspace skill with -w.")
180
+ @click.option("--name", required=True, help="Skill name to remove.")
181
+ @workspace_option()
182
+ @click.pass_obj
183
+ def skills_remove(obj: dict[str, str], name: str, workspace: str | None) -> None:
184
+ validate_resource_name(name, "skill")
185
+ core = CoreRepository()
186
+ root = workspace_config_root(core, workspace)
187
+ skill_dir = root / "skills" / name
188
+ if not skill_dir.exists():
189
+ raise click.ClickException(f"Skill not found: {name}")
190
+ shutil.rmtree(skill_dir)
191
+ scope = f"workspace:{workspace}" if workspace else "global"
192
+ click.echo(f"Removed {scope} skill: {name}")
@@ -11,6 +11,8 @@ from code_agnostic.errors import SyncAppError
11
11
  from code_agnostic.models import (
12
12
  EditorStatusRow,
13
13
  EditorSyncStatus,
14
+ ProjectStatusRow,
15
+ ProjectSyncStatus,
14
16
  WorkspaceStatusRow,
15
17
  WorkspaceSyncStatus,
16
18
  )
@@ -70,12 +72,28 @@ def status(obj: dict[str, str], app: str, verbose: bool) -> None:
70
72
  repos=[],
71
73
  )
72
74
  ]
75
+ try:
76
+ project_rows = status_service.build_project_status(
77
+ core, app_services=enabled_services
78
+ )
79
+ except SyncAppError as exc:
80
+ project_rows = [
81
+ ProjectStatusRow(
82
+ name="projects",
83
+ path=str(core.projects_path),
84
+ status=ProjectSyncStatus.ERROR,
85
+ detail=str(exc),
86
+ )
87
+ ]
73
88
  ui.render_status(
74
89
  editor_rows,
75
90
  workspace_rows,
91
+ project_rows,
76
92
  )
77
93
 
78
- if any(row.status == EditorSyncStatus.ERROR for row in editor_rows) or any(
79
- row.status == WorkspaceSyncStatus.ERROR for row in workspace_rows
94
+ if (
95
+ any(row.status == EditorSyncStatus.ERROR for row in editor_rows)
96
+ or any(row.status == WorkspaceSyncStatus.ERROR for row in workspace_rows)
97
+ or any(row.status == ProjectSyncStatus.ERROR for row in project_rows)
80
98
  ):
81
99
  raise click.exceptions.Exit(1)
@@ -0,0 +1,10 @@
1
+ from pathlib import Path
2
+
3
+ from code_agnostic.core.workspace_repository import WorkspaceConfigRepository
4
+
5
+
6
+ class ProjectConfigRepository(WorkspaceConfigRepository):
7
+ """Source repository for project-level config."""
8
+
9
+ def __init__(self, root: Path) -> None:
10
+ super().__init__(root)
@@ -158,13 +158,24 @@ class CoreRepository(BaseSourceRepository):
158
158
  def workspaces_path(self) -> Path:
159
159
  return self.config_dir / "workspaces.json"
160
160
 
161
+ @property
162
+ def projects_path(self) -> Path:
163
+ return self.config_dir / "projects.json"
164
+
161
165
  @property
162
166
  def workspaces_dir(self) -> Path:
163
167
  return self.root / "workspaces"
164
168
 
169
+ @property
170
+ def projects_dir(self) -> Path:
171
+ return self.root / "projects"
172
+
165
173
  def workspace_config_dir(self, name: str) -> Path:
166
174
  return self.workspaces_dir / name
167
175
 
176
+ def project_config_dir(self, name: str) -> Path:
177
+ return self.projects_dir / name
178
+
168
179
  def load_opencode_base(self) -> dict[str, Any]:
169
180
  if not self.opencode_base_path.exists():
170
181
  return {}
@@ -251,3 +262,82 @@ class CoreRepository(BaseSourceRepository):
251
262
  return False
252
263
  self.save_workspaces(kept)
253
264
  return True
265
+
266
+ def load_projects(self) -> list[dict[str, str]]:
267
+ payload, error = read_json_safe(self.projects_path)
268
+ if error is not None:
269
+ raise InvalidJsonFormatError(self.projects_path, error)
270
+ if payload is None:
271
+ return []
272
+ if not isinstance(payload, list):
273
+ raise InvalidConfigSchemaError(self.projects_path, "must be a JSON array")
274
+
275
+ result: list[dict[str, str]] = []
276
+ seen_names: set[str] = set()
277
+ seen_paths: set[Path] = set()
278
+ for item in payload:
279
+ if not isinstance(item, dict):
280
+ raise InvalidConfigSchemaError(
281
+ self.projects_path, "entries must be JSON objects"
282
+ )
283
+ name = item.get("name")
284
+ path = item.get("path")
285
+ if not isinstance(name, str) or not isinstance(path, str):
286
+ raise InvalidConfigSchemaError(
287
+ self.projects_path, "entries must contain string name and path"
288
+ )
289
+ normalized_name = name.strip()
290
+ if not normalized_name:
291
+ raise InvalidConfigSchemaError(
292
+ self.projects_path, "project name cannot be empty"
293
+ )
294
+ normalized_path = Path(path).expanduser().resolve()
295
+ if normalized_name in seen_names:
296
+ raise InvalidConfigSchemaError(
297
+ self.projects_path, f"duplicate project name: {normalized_name}"
298
+ )
299
+ if normalized_path in seen_paths:
300
+ raise InvalidConfigSchemaError(
301
+ self.projects_path, f"duplicate project path: {normalized_path}"
302
+ )
303
+ result.append({"name": normalized_name, "path": str(normalized_path)})
304
+ seen_names.add(normalized_name)
305
+ seen_paths.add(normalized_path)
306
+ return result
307
+
308
+ def save_projects(self, projects: list[dict[str, str]]) -> None:
309
+ serialized = sorted(
310
+ [{"name": item["name"], "path": item["path"]} for item in projects],
311
+ key=lambda item: item["name"].lower(),
312
+ )
313
+ write_json(self.projects_path, serialized)
314
+
315
+ def add_project(self, name: str, path: Path) -> None:
316
+ normalized_name = name.strip()
317
+ if not normalized_name:
318
+ raise ValueError("Project name cannot be empty")
319
+ normalized_path = path.expanduser().resolve()
320
+ if not normalized_path.exists() or not normalized_path.is_dir():
321
+ raise ValueError(
322
+ f"Project path does not exist or is not a directory: {normalized_path}"
323
+ )
324
+
325
+ projects = self.load_projects()
326
+ for item in projects:
327
+ if item["name"] == normalized_name:
328
+ raise ValueError(f"Project name already exists: {normalized_name}")
329
+ if Path(item["path"]) == normalized_path:
330
+ raise ValueError(f"Project path already exists: {normalized_path}")
331
+
332
+ projects.append({"name": normalized_name, "path": str(normalized_path)})
333
+ self.save_projects(projects)
334
+ self.project_config_dir(normalized_name).mkdir(parents=True, exist_ok=True)
335
+
336
+ def remove_project(self, name: str) -> bool:
337
+ target_name = name.strip()
338
+ projects = self.load_projects()
339
+ kept = [item for item in projects if item["name"] != target_name]
340
+ if len(kept) == len(projects):
341
+ return False
342
+ self.save_projects(kept)
343
+ return True