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.
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/PKG-INFO +23 -15
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/README.md +22 -14
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/__init__.py +1 -1
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/__main__.py +2 -0
- code_agnostic-0.3.13/code_agnostic/cli/commands/projects.py +78 -0
- code_agnostic-0.3.13/code_agnostic/cli/commands/skills.py +192 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/cli/commands/status.py +20 -2
- code_agnostic-0.3.13/code_agnostic/core/project_repository.py +10 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/core/repository.py +90 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/executor.py +122 -41
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/models.py +15 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/planner.py +124 -1
- code_agnostic-0.3.13/code_agnostic/project_artifacts.py +39 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/status.py +98 -1
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/tui/renderers.py +62 -30
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/tui/tables.py +39 -3
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic.egg-info/PKG-INFO +23 -15
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic.egg-info/SOURCES.txt +5 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/pyproject.toml +1 -1
- code_agnostic-0.3.13/tests/test_cli_projects.py +227 -0
- code_agnostic-0.3.13/tests/test_cli_skills.py +334 -0
- code_agnostic-0.3.13/tests/test_project_config_sync.py +242 -0
- code_agnostic-0.3.12/code_agnostic/cli/commands/skills.py +0 -77
- code_agnostic-0.3.12/tests/test_cli_skills.py +0 -133
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/LICENSE +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/agents/__init__.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/agents/claude.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/agents/codex.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/agents/compilers.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/agents/models.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/agents/opencode.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/agents/parser.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/__init__.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/app_id.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/apps_service.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/claude/__init__.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/claude/config_repository.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/claude/mapper.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/claude/service.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/codex/__init__.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/codex/config_repository.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/codex/mapper.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/codex/schema.json +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/codex/schema_repository.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/codex/service.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/common/__init__.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/common/compiled_planning.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/common/framework.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/common/interfaces/__init__.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/common/interfaces/mapper.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/common/interfaces/repositories.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/common/interfaces/service.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/common/loader.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/common/models.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/common/schema.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/common/symlink_planning.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/common/utils.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/cursor/__init__.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/cursor/config_repository.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/cursor/mapper.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/cursor/schema.json +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/cursor/schema_repository.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/cursor/service.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/opencode/__init__.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/opencode/config_repository.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/opencode/mapper.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/opencode/schema.json +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/opencode/schema_repository.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/apps/opencode/service.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/cli/__init__.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/cli/aliases.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/cli/commands/__init__.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/cli/commands/agents.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/cli/commands/apply.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/cli/commands/apps.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/cli/commands/explain_lossiness.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/cli/commands/import_.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/cli/commands/mcp.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/cli/commands/plan.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/cli/commands/restore.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/cli/commands/rules.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/cli/commands/validate.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/cli/commands/workspaces.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/cli/helpers.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/cli/options.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/constants.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/core/__init__.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/core/workspace_repository.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/errors.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/generated_artifacts.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/git_exclude_service.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/imports/__init__.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/imports/adapters.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/imports/filesystem.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/imports/models.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/imports/service.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/lossiness.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/mcp_service.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/rules/__init__.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/rules/compilers.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/rules/models.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/rules/parser.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/rules/repository.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/skills/__init__.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/skills/compilers.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/skills/models.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/skills/parser.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/spec/__init__.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/spec/loaders.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/spec/schemas/agent.v1.schema.json +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/spec/schemas/mcp.base.schema.json +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/spec/schemas/mcp.v1.schema.json +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/spec/schemas/rule.v1.schema.json +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/spec/schemas/skill.v1.schema.json +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/tui/__init__.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/tui/enums.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/tui/import_selector.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/tui/sections.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/utils.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/validation.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/workspace_artifacts.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic/workspaces.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic.egg-info/dependency_links.txt +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic.egg-info/entry_points.txt +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic.egg-info/requires.txt +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/code_agnostic.egg-info/top_level.txt +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/setup.cfg +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_cli_agents.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_cli_aliases.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_cli_apply_apps.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_cli_apply_codex.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_cli_apply_cursor.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_cli_apply_target.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_cli_apps.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_cli_explain_lossiness.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_cli_flags.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_cli_git_exclude.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_cli_import.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_cli_import_interactive.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_cli_mcp.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_cli_module_organization.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_cli_plan.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_cli_restore.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_cli_rules.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_cli_status.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_cli_validate.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_cli_workspace_resolution.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_cli_workspaces.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_common_mcp_to_dto.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_common_repository.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_compiled_planning.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_dto_to_common_mcp.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_git_exclude_service.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_mcp_service.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_planner_executor.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_planner_rules.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_symlink_planning.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_sync_plan_model.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_transactional_executor.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_utils.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_version.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_workspace_config_sync.py +0 -0
- {code_agnostic-0.3.12 → code_agnostic-0.3.13}/tests/test_workspace_repo_status.py +0 -0
- {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.
|
|
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
|
|
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
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
233
|
+
Install a local skill directory into managed source:
|
|
234
234
|
|
|
235
235
|
```bash
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
|
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
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
208
|
+
Install a local skill directory into managed source:
|
|
209
209
|
|
|
210
210
|
```bash
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
|
|
@@ -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
|
|
79
|
-
row.status ==
|
|
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
|