code-agnostic 0.3.13__tar.gz → 0.3.14__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.13 → code_agnostic-0.3.14}/PKG-INFO +19 -4
  2. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/README.md +18 -3
  3. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/__init__.py +1 -1
  4. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/cli/commands/skills.py +74 -30
  5. code_agnostic-0.3.14/code_agnostic/skills/install_sources.py +369 -0
  6. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic.egg-info/PKG-INFO +19 -4
  7. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic.egg-info/SOURCES.txt +2 -0
  8. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/pyproject.toml +1 -1
  9. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_cli_skills.py +88 -1
  10. code_agnostic-0.3.14/tests/test_skill_install_sources.py +182 -0
  11. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/LICENSE +0 -0
  12. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/__main__.py +0 -0
  13. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/agents/__init__.py +0 -0
  14. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/agents/claude.py +0 -0
  15. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/agents/codex.py +0 -0
  16. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/agents/compilers.py +0 -0
  17. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/agents/models.py +0 -0
  18. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/agents/opencode.py +0 -0
  19. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/agents/parser.py +0 -0
  20. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/__init__.py +0 -0
  21. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/app_id.py +0 -0
  22. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/apps_service.py +0 -0
  23. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/claude/__init__.py +0 -0
  24. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/claude/config_repository.py +0 -0
  25. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/claude/mapper.py +0 -0
  26. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/claude/service.py +0 -0
  27. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/codex/__init__.py +0 -0
  28. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/codex/config_repository.py +0 -0
  29. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/codex/mapper.py +0 -0
  30. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/codex/schema.json +0 -0
  31. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/codex/schema_repository.py +0 -0
  32. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/codex/service.py +0 -0
  33. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/common/__init__.py +0 -0
  34. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/common/compiled_planning.py +0 -0
  35. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/common/framework.py +0 -0
  36. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/common/interfaces/__init__.py +0 -0
  37. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/common/interfaces/mapper.py +0 -0
  38. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/common/interfaces/repositories.py +0 -0
  39. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/common/interfaces/service.py +0 -0
  40. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/common/loader.py +0 -0
  41. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/common/models.py +0 -0
  42. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/common/schema.py +0 -0
  43. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/common/symlink_planning.py +0 -0
  44. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/common/utils.py +0 -0
  45. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/cursor/__init__.py +0 -0
  46. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/cursor/config_repository.py +0 -0
  47. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/cursor/mapper.py +0 -0
  48. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/cursor/schema.json +0 -0
  49. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/cursor/schema_repository.py +0 -0
  50. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/cursor/service.py +0 -0
  51. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/opencode/__init__.py +0 -0
  52. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/opencode/config_repository.py +0 -0
  53. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/opencode/mapper.py +0 -0
  54. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/opencode/schema.json +0 -0
  55. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/opencode/schema_repository.py +0 -0
  56. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/opencode/service.py +0 -0
  57. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/cli/__init__.py +0 -0
  58. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/cli/aliases.py +0 -0
  59. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/cli/commands/__init__.py +0 -0
  60. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/cli/commands/agents.py +0 -0
  61. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/cli/commands/apply.py +0 -0
  62. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/cli/commands/apps.py +0 -0
  63. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/cli/commands/explain_lossiness.py +0 -0
  64. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/cli/commands/import_.py +0 -0
  65. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/cli/commands/mcp.py +0 -0
  66. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/cli/commands/plan.py +0 -0
  67. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/cli/commands/projects.py +0 -0
  68. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/cli/commands/restore.py +0 -0
  69. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/cli/commands/rules.py +0 -0
  70. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/cli/commands/status.py +0 -0
  71. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/cli/commands/validate.py +0 -0
  72. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/cli/commands/workspaces.py +0 -0
  73. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/cli/helpers.py +0 -0
  74. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/cli/options.py +0 -0
  75. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/constants.py +0 -0
  76. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/core/__init__.py +0 -0
  77. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/core/project_repository.py +0 -0
  78. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/core/repository.py +0 -0
  79. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/core/workspace_repository.py +0 -0
  80. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/errors.py +0 -0
  81. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/executor.py +0 -0
  82. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/generated_artifacts.py +0 -0
  83. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/git_exclude_service.py +0 -0
  84. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/imports/__init__.py +0 -0
  85. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/imports/adapters.py +0 -0
  86. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/imports/filesystem.py +0 -0
  87. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/imports/models.py +0 -0
  88. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/imports/service.py +0 -0
  89. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/lossiness.py +0 -0
  90. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/mcp_service.py +0 -0
  91. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/models.py +0 -0
  92. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/planner.py +0 -0
  93. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/project_artifacts.py +0 -0
  94. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/rules/__init__.py +0 -0
  95. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/rules/compilers.py +0 -0
  96. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/rules/models.py +0 -0
  97. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/rules/parser.py +0 -0
  98. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/rules/repository.py +0 -0
  99. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/skills/__init__.py +0 -0
  100. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/skills/compilers.py +0 -0
  101. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/skills/models.py +0 -0
  102. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/skills/parser.py +0 -0
  103. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/spec/__init__.py +0 -0
  104. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/spec/loaders.py +0 -0
  105. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/spec/schemas/agent.v1.schema.json +0 -0
  106. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/spec/schemas/mcp.base.schema.json +0 -0
  107. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/spec/schemas/mcp.v1.schema.json +0 -0
  108. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/spec/schemas/rule.v1.schema.json +0 -0
  109. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/spec/schemas/skill.v1.schema.json +0 -0
  110. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/status.py +0 -0
  111. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/tui/__init__.py +0 -0
  112. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/tui/enums.py +0 -0
  113. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/tui/import_selector.py +0 -0
  114. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/tui/renderers.py +0 -0
  115. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/tui/sections.py +0 -0
  116. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/tui/tables.py +0 -0
  117. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/utils.py +0 -0
  118. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/validation.py +0 -0
  119. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/workspace_artifacts.py +0 -0
  120. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/workspaces.py +0 -0
  121. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic.egg-info/dependency_links.txt +0 -0
  122. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic.egg-info/entry_points.txt +0 -0
  123. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic.egg-info/requires.txt +0 -0
  124. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic.egg-info/top_level.txt +0 -0
  125. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/setup.cfg +0 -0
  126. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_cli_agents.py +0 -0
  127. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_cli_aliases.py +0 -0
  128. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_cli_apply_apps.py +0 -0
  129. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_cli_apply_codex.py +0 -0
  130. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_cli_apply_cursor.py +0 -0
  131. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_cli_apply_target.py +0 -0
  132. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_cli_apps.py +0 -0
  133. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_cli_explain_lossiness.py +0 -0
  134. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_cli_flags.py +0 -0
  135. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_cli_git_exclude.py +0 -0
  136. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_cli_import.py +0 -0
  137. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_cli_import_interactive.py +0 -0
  138. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_cli_mcp.py +0 -0
  139. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_cli_module_organization.py +0 -0
  140. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_cli_plan.py +0 -0
  141. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_cli_projects.py +0 -0
  142. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_cli_restore.py +0 -0
  143. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_cli_rules.py +0 -0
  144. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_cli_status.py +0 -0
  145. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_cli_validate.py +0 -0
  146. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_cli_workspace_resolution.py +0 -0
  147. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_cli_workspaces.py +0 -0
  148. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_common_mcp_to_dto.py +0 -0
  149. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_common_repository.py +0 -0
  150. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_compiled_planning.py +0 -0
  151. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_dto_to_common_mcp.py +0 -0
  152. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_git_exclude_service.py +0 -0
  153. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_mcp_service.py +0 -0
  154. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_planner_executor.py +0 -0
  155. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_planner_rules.py +0 -0
  156. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_project_config_sync.py +0 -0
  157. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_symlink_planning.py +0 -0
  158. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_sync_plan_model.py +0 -0
  159. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_transactional_executor.py +0 -0
  160. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_utils.py +0 -0
  161. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_version.py +0 -0
  162. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_workspace_config_sync.py +0 -0
  163. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_workspace_repo_status.py +0 -0
  164. {code_agnostic-0.3.13 → code_agnostic-0.3.14}/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.13
3
+ Version: 0.3.14
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
@@ -230,7 +230,8 @@ code-agnostic skills list
230
230
  code-agnostic agents list
231
231
  ```
232
232
 
233
- Install a local skill directory into managed source:
233
+ Install copies a skill into managed source first for the chosen scope. Run
234
+ `plan` / `apply` afterward to generate target app files.
234
235
 
235
236
  ```bash
236
237
  code-agnostic skills install ./my-skill --global
@@ -241,6 +242,20 @@ code-agnostic plan
241
242
  code-agnostic apply
242
243
  ```
243
244
 
245
+ Remote GitHub-style sources are also supported:
246
+
247
+ ```bash
248
+ code-agnostic skills install owner/repo --global
249
+ code-agnostic skills install https://github.com/owner/repo --workspace myworkspace
250
+ code-agnostic skills install https://github.com/owner/repo/tree/main/path/to/skills --skill reviewer --skill triage --project myproject
251
+ ```
252
+
253
+ Remote sources may be an `owner/repo` shorthand, a GitHub repository URL, or a
254
+ GitHub tree URL. When a remote source contains multiple skill candidates,
255
+ repeat `--skill` to select the intended skills; otherwise install fails instead
256
+ of guessing. Remote installs still copy the selected skill into the
257
+ `code-agnostic` source of truth before any generated target outputs are written.
258
+
244
259
  Global skills live under `~/.config/code-agnostic/skills`. Workspace-local
245
260
  skills live under `~/.config/code-agnostic/workspaces/<name>/skills` and can be
246
261
  inspected with `code-agnostic skills list -w <name>`. Project-local skills live
@@ -252,7 +267,7 @@ skills and agents are written under `~/.claude/skills` and `~/.claude/agents`,
252
267
  with workspace/project copies under repo-local `.claude/skills` and
253
268
  `.claude/agents`.
254
269
 
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`.
270
+ If a target app discovers user-created repo-local skill folders such as `.agents/skills`, `.opencode/skills`, or `.claude/skills`, treat those as unmanaged app inputs unless they were generated from `code-agnostic` project source. Workspace and project sync write only the exact generated paths recorded in their `.sync-state.json` files.
256
271
 
257
272
  Planned convenience command:
258
273
 
@@ -266,7 +281,7 @@ first implementation slice.
266
281
 
267
282
  ### Workspaces
268
283
 
269
- 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 write project-root `opencode.json` files that 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"]`. Workspace source config, skills, and agents are propagated into repo-local generated paths for OpenCode, Cursor, Codex, and Claude; user-created project-local skill folders remain unmanaged until project-scoped installs are supported.
284
+ 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 write project-root `opencode.json` files that 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"]`. Workspace source config, skills, and agents are propagated into repo-local generated paths for OpenCode, Cursor, Codex, and Claude; user-created target app skill folders remain unmanaged unless they were generated from managed source.
270
285
 
271
286
  Cursor propagation intentionally stays to repo-local MCP, skills, and agents; it does not copy the shared workspace `AGENTS.md` into child repos.
272
287
 
@@ -205,7 +205,8 @@ code-agnostic skills list
205
205
  code-agnostic agents list
206
206
  ```
207
207
 
208
- Install a local skill directory into managed source:
208
+ Install copies a skill into managed source first for the chosen scope. Run
209
+ `plan` / `apply` afterward to generate target app files.
209
210
 
210
211
  ```bash
211
212
  code-agnostic skills install ./my-skill --global
@@ -216,6 +217,20 @@ code-agnostic plan
216
217
  code-agnostic apply
217
218
  ```
218
219
 
220
+ Remote GitHub-style sources are also supported:
221
+
222
+ ```bash
223
+ code-agnostic skills install owner/repo --global
224
+ code-agnostic skills install https://github.com/owner/repo --workspace myworkspace
225
+ code-agnostic skills install https://github.com/owner/repo/tree/main/path/to/skills --skill reviewer --skill triage --project myproject
226
+ ```
227
+
228
+ Remote sources may be an `owner/repo` shorthand, a GitHub repository URL, or a
229
+ GitHub tree URL. When a remote source contains multiple skill candidates,
230
+ repeat `--skill` to select the intended skills; otherwise install fails instead
231
+ of guessing. Remote installs still copy the selected skill into the
232
+ `code-agnostic` source of truth before any generated target outputs are written.
233
+
219
234
  Global skills live under `~/.config/code-agnostic/skills`. Workspace-local
220
235
  skills live under `~/.config/code-agnostic/workspaces/<name>/skills` and can be
221
236
  inspected with `code-agnostic skills list -w <name>`. Project-local skills live
@@ -227,7 +242,7 @@ skills and agents are written under `~/.claude/skills` and `~/.claude/agents`,
227
242
  with workspace/project copies under repo-local `.claude/skills` and
228
243
  `.claude/agents`.
229
244
 
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`.
245
+ If a target app discovers user-created repo-local skill folders such as `.agents/skills`, `.opencode/skills`, or `.claude/skills`, treat those as unmanaged app inputs unless they were generated from `code-agnostic` project source. Workspace and project sync write only the exact generated paths recorded in their `.sync-state.json` files.
231
246
 
232
247
  Planned convenience command:
233
248
 
@@ -241,7 +256,7 @@ first implementation slice.
241
256
 
242
257
  ### Workspaces
243
258
 
244
- 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 write project-root `opencode.json` files that 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"]`. Workspace source config, skills, and agents are propagated into repo-local generated paths for OpenCode, Cursor, Codex, and Claude; user-created project-local skill folders remain unmanaged until project-scoped installs are supported.
259
+ 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 write project-root `opencode.json` files that 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"]`. Workspace source config, skills, and agents are propagated into repo-local generated paths for OpenCode, Cursor, Codex, and Claude; user-created target app skill folders remain unmanaged unless they were generated from managed source.
245
260
 
246
261
  Cursor propagation intentionally stays to repo-local MCP, skills, and agents; it does not copy the shared workspace `AGENTS.md` into child repos.
247
262
 
@@ -1,3 +1,3 @@
1
1
  __all__ = ["__version__"]
2
2
 
3
- __version__ = "0.3.13"
3
+ __version__ = "0.3.14"
@@ -10,17 +10,15 @@ from code_agnostic.cli.helpers import validate_resource_name, workspace_config_r
10
10
  from code_agnostic.cli.options import workspace_option
11
11
  from code_agnostic.core.repository import CoreRepository
12
12
  from code_agnostic.errors import SyncAppError
13
+ from code_agnostic.skills.install_sources import (
14
+ SkillInstallSourceError,
15
+ cleanup_skill_install_resolution,
16
+ resolve_skill_install_source,
17
+ )
13
18
  from code_agnostic.tui import SyncConsoleUI
14
19
  from code_agnostic.utils import compact_home_path
15
20
 
16
21
 
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
22
  def _entries_containing_cwd(
25
23
  entries: list[dict[str, str]], cwd: Path
26
24
  ) -> list[dict[str, str]]:
@@ -85,6 +83,32 @@ def _install_root(
85
83
  )
86
84
 
87
85
 
86
+ def _preflight_skill_destinations(
87
+ source_dirs: tuple[Path, ...], root: Path, scope: str
88
+ ) -> list[tuple[str, Path, Path]]:
89
+ installs: list[tuple[str, Path, Path]] = []
90
+ names = _validate_skill_source_names(source_dirs)
91
+ for name, source_dir in zip(names, source_dirs, strict=True):
92
+ destination = root / "skills" / name
93
+ if destination.exists():
94
+ raise click.ClickException(f"Skill already exists: {scope}:{name}")
95
+ installs.append((name, source_dir, destination))
96
+ return installs
97
+
98
+
99
+ def _validate_skill_source_names(source_dirs: tuple[Path, ...]) -> list[str]:
100
+ names: list[str] = []
101
+ seen_names: set[str] = set()
102
+ for source_dir in source_dirs:
103
+ name = source_dir.name
104
+ validate_resource_name(name, "skill")
105
+ if name in seen_names:
106
+ raise click.ClickException(f"Duplicate skill name in source: {name}")
107
+ seen_names.add(name)
108
+ names.append(name)
109
+ return names
110
+
111
+
88
112
  @click.group(
89
113
  help=(
90
114
  "Manage source skill definitions. Commands use global source by default; "
@@ -95,28 +119,37 @@ def skills() -> None:
95
119
  pass
96
120
 
97
121
 
98
- @skills.command("install", help="Install a local skill directory into source config.")
99
- @click.argument("source", type=click.Path(path_type=Path))
122
+ @skills.command("install", help="Install a skill source into source config.")
123
+ @click.argument("source")
124
+ @click.option(
125
+ "--skill",
126
+ "skill_selectors",
127
+ multiple=True,
128
+ help="Select a skill by name or source path; repeat to install multiple.",
129
+ )
100
130
  @click.option("--global", "global_scope", is_flag=True, help="Install globally.")
101
131
  @workspace_option()
102
132
  @click.option("--project", help="Install into a registered project source.")
103
133
  @click.pass_obj
104
134
  def skills_install(
105
135
  obj: dict[str, str],
106
- source: Path,
136
+ source: str,
137
+ skill_selectors: tuple[str, ...],
107
138
  global_scope: bool,
108
139
  workspace: str | None,
109
140
  project: str | None,
110
141
  ) -> 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")
142
+ resolution = None
143
+ source_path = Path(source).expanduser()
144
+ if source_path.exists():
145
+ try:
146
+ resolution = resolve_skill_install_source(
147
+ source,
148
+ skill_selectors=skill_selectors,
149
+ )
150
+ _validate_skill_source_names(resolution.skill_dirs)
151
+ except SkillInstallSourceError as exc:
152
+ raise click.ClickException(str(exc)) from exc
120
153
 
121
154
  core = CoreRepository()
122
155
  root, scope, scope_note = _install_root(
@@ -125,17 +158,28 @@ def skills_install(
125
158
  workspace=workspace,
126
159
  project=project,
127
160
  )
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)}")
161
+ try:
162
+ if resolution is None:
163
+ resolution = resolve_skill_install_source(
164
+ source,
165
+ skill_selectors=skill_selectors,
166
+ )
167
+ installs = _preflight_skill_destinations(resolution.skill_dirs, root, scope)
168
+ for _name, _source_dir, destination in installs:
169
+ destination.parent.mkdir(parents=True, exist_ok=True)
170
+ for name, source_dir, destination in installs:
171
+ shutil.copytree(source_dir, destination)
172
+ if scope_note is not None:
173
+ click.echo(scope_note)
174
+ scope_note = None
175
+ click.echo(f"Installed {scope} skill: {name}")
176
+ click.echo(f"Source: {compact_home_path(source_dir)}")
177
+ click.echo(f"Destination: {compact_home_path(destination)}")
178
+ except SkillInstallSourceError as exc:
179
+ raise click.ClickException(str(exc)) from exc
180
+ finally:
181
+ if resolution is not None:
182
+ cleanup_skill_install_resolution(resolution)
139
183
 
140
184
 
141
185
  @skills.command("list", help="List global skills, or workspace skills with -w.")
@@ -0,0 +1,369 @@
1
+ """Resolve skill install sources into local skill directories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import os
7
+ from pathlib import Path
8
+ import re
9
+ import shutil
10
+ import stat
11
+ import subprocess
12
+ import tempfile
13
+ from urllib.parse import unquote, urlparse
14
+
15
+ from code_agnostic.errors import SyncAppError
16
+
17
+ _GITHUB_SHORTHAND_RE = re.compile(
18
+ r"^(?P<owner>[A-Za-z0-9_.-]+)/(?P<repo>[A-Za-z0-9_.-]+)$"
19
+ )
20
+
21
+
22
+ class SkillInstallSourceError(SyncAppError):
23
+ """Raised when a skill install source cannot be resolved."""
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class ParsedSkillInstallSource:
28
+ raw: str
29
+ kind: str
30
+ path: Path | None = None
31
+ clone_url: str | None = None
32
+ tree_parts: tuple[str, ...] = ()
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class SkillInstallCandidate:
37
+ name: str
38
+ path: Path
39
+ relative_path: str
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class SkillInstallResolution:
44
+ source: str
45
+ root: Path
46
+ skill_dirs: tuple[Path, ...]
47
+ candidates: tuple[SkillInstallCandidate, ...]
48
+ checkout_dir: Path | None = None
49
+ work_dir: Path | None = None
50
+
51
+
52
+ def parse_skill_install_source(source: str | Path) -> ParsedSkillInstallSource:
53
+ """Parse a local path or supported GitHub source."""
54
+
55
+ raw = str(source)
56
+ path = Path(raw).expanduser()
57
+ if path.exists():
58
+ return ParsedSkillInstallSource(
59
+ raw=raw,
60
+ kind="local",
61
+ path=path.resolve(),
62
+ )
63
+
64
+ github_source = _parse_github_source(raw)
65
+ if github_source is not None:
66
+ return github_source
67
+
68
+ if urlparse(raw).scheme:
69
+ raise SkillInstallSourceError(f"Unsupported skill source URL: {raw}")
70
+
71
+ raise SkillInstallSourceError(f"Skill source does not exist: {path}")
72
+
73
+
74
+ def resolve_skill_install_source(
75
+ source: str | Path,
76
+ *,
77
+ skill_selectors: list[str] | tuple[str, ...] = (),
78
+ work_dir: Path | None = None,
79
+ ) -> SkillInstallResolution:
80
+ """Resolve a source into selected local skill directories.
81
+
82
+ Remote sources are cloned into ``work_dir`` when provided, or into a new
83
+ temporary directory. The caller owns cleanup for returned paths.
84
+ """
85
+
86
+ parsed = parse_skill_install_source(source)
87
+ checkout_dir: Path | None = None
88
+ checkout_work_dir: Path | None = None
89
+
90
+ try:
91
+ if parsed.kind == "local":
92
+ if parsed.path is None:
93
+ raise SkillInstallSourceError(
94
+ f"Invalid local skill source: {parsed.raw}"
95
+ )
96
+ root = parsed.path
97
+ elif parsed.kind == "github":
98
+ if parsed.clone_url is None:
99
+ raise SkillInstallSourceError(
100
+ f"Invalid GitHub skill source: {parsed.raw}"
101
+ )
102
+ checkout_dir, checkout_work_dir = _clone_git_source(
103
+ parsed.clone_url, work_dir=work_dir
104
+ )
105
+ root = checkout_dir
106
+ if parsed.tree_parts:
107
+ subpath = _checkout_tree_parts(checkout_dir, parsed.tree_parts)
108
+ root = checkout_dir / subpath
109
+ else:
110
+ raise SkillInstallSourceError(f"Unsupported skill source: {parsed.raw}")
111
+
112
+ candidates = _discover_skill_candidates(root)
113
+ selected = _select_candidates(parsed.raw, candidates, skill_selectors)
114
+ return SkillInstallResolution(
115
+ source=parsed.raw,
116
+ root=root,
117
+ skill_dirs=tuple(candidate.path for candidate in selected),
118
+ candidates=tuple(selected),
119
+ checkout_dir=checkout_dir,
120
+ work_dir=checkout_work_dir,
121
+ )
122
+ except Exception:
123
+ if checkout_work_dir is not None and work_dir is None:
124
+ _remove_tree(checkout_work_dir)
125
+ raise
126
+
127
+
128
+ def cleanup_skill_install_resolution(resolution: SkillInstallResolution) -> None:
129
+ """Remove temporary checkout files created for a resolution."""
130
+
131
+ if resolution.work_dir is not None:
132
+ _remove_tree(resolution.work_dir)
133
+
134
+
135
+ def _parse_github_source(raw: str) -> ParsedSkillInstallSource | None:
136
+ shorthand = _GITHUB_SHORTHAND_RE.match(raw)
137
+ if shorthand is not None:
138
+ owner = shorthand.group("owner")
139
+ repo = shorthand.group("repo").removesuffix(".git")
140
+ return ParsedSkillInstallSource(
141
+ raw=raw,
142
+ kind="github",
143
+ clone_url=_github_clone_url(owner, repo),
144
+ )
145
+
146
+ parsed = urlparse(raw)
147
+ if parsed.scheme not in {"http", "https"} or parsed.netloc.lower() != "github.com":
148
+ return None
149
+
150
+ parts = [unquote(part) for part in parsed.path.split("/") if part]
151
+ if len(parts) < 2:
152
+ raise SkillInstallSourceError(f"Invalid GitHub skill source: {raw}")
153
+
154
+ owner = parts[0]
155
+ repo = parts[1].removesuffix(".git")
156
+ if len(parts) == 2:
157
+ return ParsedSkillInstallSource(
158
+ raw=raw,
159
+ kind="github",
160
+ clone_url=_github_clone_url(owner, repo),
161
+ )
162
+
163
+ if len(parts) >= 4 and parts[2] == "tree":
164
+ tree_parts = tuple(parts[3:])
165
+ _validate_tree_parts(raw, tree_parts)
166
+ return ParsedSkillInstallSource(
167
+ raw=raw,
168
+ kind="github",
169
+ clone_url=_github_clone_url(owner, repo),
170
+ tree_parts=tree_parts,
171
+ )
172
+
173
+ raise SkillInstallSourceError(
174
+ "Unsupported GitHub skill source path. Expected a repository URL or "
175
+ f"/tree/<ref>/<path>: {raw}"
176
+ )
177
+
178
+
179
+ def _github_clone_url(owner: str, repo: str) -> str:
180
+ return f"https://github.com/{owner}/{repo}.git"
181
+
182
+
183
+ def _validate_tree_parts(raw: str, parts: tuple[str, ...]) -> None:
184
+ if not parts or any(part in {"", ".."} for part in parts):
185
+ raise SkillInstallSourceError(f"Invalid GitHub tree source: {raw}")
186
+
187
+
188
+ def _clone_git_source(source: str, *, work_dir: Path | None) -> tuple[Path, Path]:
189
+ checkout_work_dir = (
190
+ work_dir.expanduser().resolve()
191
+ if work_dir is not None
192
+ else Path(tempfile.mkdtemp(prefix="code-agnostic-skill-source-"))
193
+ )
194
+ checkout_work_dir.mkdir(parents=True, exist_ok=True)
195
+ checkout_dir = _unique_child(checkout_work_dir, "checkout")
196
+ try:
197
+ _run_git(["clone", "--quiet", source, str(checkout_dir)])
198
+ return checkout_dir, checkout_work_dir
199
+ except Exception:
200
+ if work_dir is None:
201
+ _remove_tree(checkout_work_dir)
202
+ raise
203
+
204
+
205
+ def _checkout_tree_parts(checkout_dir: Path, tree_parts: tuple[str, ...]) -> Path:
206
+ for ref_part_count in range(len(tree_parts), 0, -1):
207
+ ref = "/".join(tree_parts[:ref_part_count])
208
+ result = _run_git(
209
+ ["checkout", "--quiet", ref],
210
+ cwd=checkout_dir,
211
+ check=False,
212
+ )
213
+ if result.returncode == 0:
214
+ path_parts = tree_parts[ref_part_count:]
215
+ subpath = Path(*path_parts) if path_parts else Path()
216
+ target = checkout_dir / subpath
217
+ if not target.exists():
218
+ raise SkillInstallSourceError(
219
+ f"GitHub tree path does not exist after checkout: {subpath}"
220
+ )
221
+ return subpath
222
+
223
+ raise SkillInstallSourceError(
224
+ "Could not check out a ref from GitHub tree source: " + "/".join(tree_parts)
225
+ )
226
+
227
+
228
+ def _run_git(
229
+ args: list[str],
230
+ *,
231
+ cwd: Path | None = None,
232
+ check: bool = True,
233
+ ) -> subprocess.CompletedProcess[str]:
234
+ try:
235
+ result = subprocess.run(
236
+ ["git", *args],
237
+ cwd=cwd,
238
+ check=False,
239
+ capture_output=True,
240
+ text=True,
241
+ )
242
+ except FileNotFoundError as exc:
243
+ raise SkillInstallSourceError(
244
+ "git is required to resolve this skill source"
245
+ ) from exc
246
+
247
+ if check and result.returncode != 0:
248
+ detail = result.stderr.strip() or result.stdout.strip() or "git command failed"
249
+ raise SkillInstallSourceError(detail)
250
+ return result
251
+
252
+
253
+ def _unique_child(parent: Path, name: str) -> Path:
254
+ candidate = parent / name
255
+ if not candidate.exists():
256
+ return candidate
257
+ index = 2
258
+ while True:
259
+ candidate = parent / f"{name}-{index}"
260
+ if not candidate.exists():
261
+ return candidate
262
+ index += 1
263
+
264
+
265
+ def _remove_tree(path: Path) -> None:
266
+ def handle_readonly(
267
+ func: object,
268
+ path_string: str,
269
+ _exc_info: object,
270
+ ) -> None:
271
+ os.chmod(path_string, stat.S_IWRITE)
272
+ func(path_string) # type: ignore[operator]
273
+
274
+ shutil.rmtree(path, onerror=handle_readonly)
275
+
276
+
277
+ def _discover_skill_candidates(root: Path) -> list[SkillInstallCandidate]:
278
+ if not root.exists():
279
+ raise SkillInstallSourceError(f"Skill source path does not exist: {root}")
280
+ if not root.is_dir():
281
+ raise SkillInstallSourceError(f"Skill source is not a directory: {root}")
282
+ if _is_skill_source(root):
283
+ return [_candidate_for(root, root)]
284
+
285
+ candidates: list[SkillInstallCandidate] = []
286
+ for dirpath, dirnames, _filenames in os.walk(root):
287
+ dirnames[:] = [
288
+ dirname for dirname in dirnames if dirname not in {".git", "__pycache__"}
289
+ ]
290
+ path = Path(dirpath)
291
+ if _is_skill_source(path):
292
+ candidates.append(_candidate_for(path, root))
293
+ dirnames[:] = []
294
+
295
+ return sorted(candidates, key=lambda candidate: candidate.relative_path)
296
+
297
+
298
+ def _is_skill_source(path: Path) -> bool:
299
+ return path.is_dir() and (
300
+ (path / "SKILL.md").exists()
301
+ or ((path / "meta.yaml").exists() and (path / "prompt.md").exists())
302
+ )
303
+
304
+
305
+ def _candidate_for(path: Path, root: Path) -> SkillInstallCandidate:
306
+ relative_path = "." if path == root else path.relative_to(root).as_posix()
307
+ return SkillInstallCandidate(
308
+ name=path.name,
309
+ path=path,
310
+ relative_path=relative_path,
311
+ )
312
+
313
+
314
+ def _select_candidates(
315
+ source: str,
316
+ candidates: list[SkillInstallCandidate],
317
+ selectors: list[str] | tuple[str, ...],
318
+ ) -> list[SkillInstallCandidate]:
319
+ if not candidates:
320
+ raise SkillInstallSourceError(f"No skill directories found in source: {source}")
321
+
322
+ if not selectors:
323
+ if len(candidates) == 1:
324
+ return candidates
325
+ raise SkillInstallSourceError(
326
+ "Multiple skill candidates found in source: "
327
+ + _format_candidates(candidates)
328
+ + ". Pass --skill with one or more candidate names or paths."
329
+ )
330
+
331
+ selected: list[SkillInstallCandidate] = []
332
+ selected_paths: set[Path] = set()
333
+ for selector in selectors:
334
+ normalized = _normalize_selector(selector)
335
+ matches = [
336
+ candidate
337
+ for candidate in candidates
338
+ if normalized in {candidate.name, candidate.relative_path}
339
+ ]
340
+ if not matches:
341
+ raise SkillInstallSourceError(
342
+ f"Skill selector did not match any candidates: {selector}. "
343
+ f"Available candidates: {_format_candidates(candidates)}"
344
+ )
345
+ if len(matches) > 1:
346
+ raise SkillInstallSourceError(
347
+ f"Skill selector is ambiguous: {selector}. "
348
+ f"Matched candidates: {_format_candidates(matches)}. "
349
+ "Pass --skill with a candidate path."
350
+ )
351
+ match = matches[0]
352
+ if match.path not in selected_paths:
353
+ selected.append(match)
354
+ selected_paths.add(match.path)
355
+
356
+ return selected
357
+
358
+
359
+ def _normalize_selector(selector: str) -> str:
360
+ normalized = selector.strip().replace("\\", "/").strip("/")
361
+ while normalized.startswith("./"):
362
+ normalized = normalized[2:]
363
+ if not normalized:
364
+ raise SkillInstallSourceError("Skill selector cannot be empty")
365
+ return normalized
366
+
367
+
368
+ def _format_candidates(candidates: list[SkillInstallCandidate]) -> str:
369
+ return ", ".join(candidate.relative_path for candidate in candidates)