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.
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/PKG-INFO +19 -4
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/README.md +18 -3
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/__init__.py +1 -1
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/cli/commands/skills.py +74 -30
- code_agnostic-0.3.14/code_agnostic/skills/install_sources.py +369 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic.egg-info/PKG-INFO +19 -4
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic.egg-info/SOURCES.txt +2 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/pyproject.toml +1 -1
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_cli_skills.py +88 -1
- code_agnostic-0.3.14/tests/test_skill_install_sources.py +182 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/LICENSE +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/__main__.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/agents/__init__.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/agents/claude.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/agents/codex.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/agents/compilers.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/agents/models.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/agents/opencode.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/agents/parser.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/__init__.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/app_id.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/apps_service.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/claude/__init__.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/claude/config_repository.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/claude/mapper.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/claude/service.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/codex/__init__.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/codex/config_repository.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/codex/mapper.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/codex/schema.json +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/codex/schema_repository.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/codex/service.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/common/__init__.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/common/compiled_planning.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/common/framework.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/common/interfaces/__init__.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/common/interfaces/mapper.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/common/interfaces/repositories.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/common/interfaces/service.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/common/loader.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/common/models.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/common/schema.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/common/symlink_planning.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/common/utils.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/cursor/__init__.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/cursor/config_repository.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/cursor/mapper.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/cursor/schema.json +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/cursor/schema_repository.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/cursor/service.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/opencode/__init__.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/opencode/config_repository.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/opencode/mapper.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/opencode/schema.json +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/opencode/schema_repository.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/apps/opencode/service.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/cli/__init__.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/cli/aliases.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/cli/commands/__init__.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/cli/commands/agents.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/cli/commands/apply.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/cli/commands/apps.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/cli/commands/explain_lossiness.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/cli/commands/import_.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/cli/commands/mcp.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/cli/commands/plan.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/cli/commands/projects.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/cli/commands/restore.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/cli/commands/rules.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/cli/commands/status.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/cli/commands/validate.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/cli/commands/workspaces.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/cli/helpers.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/cli/options.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/constants.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/core/__init__.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/core/project_repository.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/core/repository.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/core/workspace_repository.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/errors.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/executor.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/generated_artifacts.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/git_exclude_service.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/imports/__init__.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/imports/adapters.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/imports/filesystem.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/imports/models.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/imports/service.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/lossiness.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/mcp_service.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/models.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/planner.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/project_artifacts.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/rules/__init__.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/rules/compilers.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/rules/models.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/rules/parser.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/rules/repository.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/skills/__init__.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/skills/compilers.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/skills/models.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/skills/parser.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/spec/__init__.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/spec/loaders.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/spec/schemas/agent.v1.schema.json +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/spec/schemas/mcp.base.schema.json +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/spec/schemas/mcp.v1.schema.json +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/spec/schemas/rule.v1.schema.json +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/spec/schemas/skill.v1.schema.json +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/status.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/tui/__init__.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/tui/enums.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/tui/import_selector.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/tui/renderers.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/tui/sections.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/tui/tables.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/utils.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/validation.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/workspace_artifacts.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic/workspaces.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic.egg-info/dependency_links.txt +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic.egg-info/entry_points.txt +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic.egg-info/requires.txt +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/code_agnostic.egg-info/top_level.txt +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/setup.cfg +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_cli_agents.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_cli_aliases.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_cli_apply_apps.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_cli_apply_codex.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_cli_apply_cursor.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_cli_apply_target.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_cli_apps.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_cli_explain_lossiness.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_cli_flags.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_cli_git_exclude.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_cli_import.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_cli_import_interactive.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_cli_mcp.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_cli_module_organization.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_cli_plan.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_cli_projects.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_cli_restore.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_cli_rules.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_cli_status.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_cli_validate.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_cli_workspace_resolution.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_cli_workspaces.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_common_mcp_to_dto.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_common_repository.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_compiled_planning.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_dto_to_common_mcp.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_git_exclude_service.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_mcp_service.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_planner_executor.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_planner_rules.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_project_config_sync.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_symlink_planning.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_sync_plan_model.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_transactional_executor.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_utils.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_version.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_workspace_config_sync.py +0 -0
- {code_agnostic-0.3.13 → code_agnostic-0.3.14}/tests/test_workspace_repo_status.py +0 -0
- {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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
|
@@ -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
|
|
99
|
-
@click.argument("source"
|
|
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:
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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)
|