code-agnostic 0.1.0__py3-none-any.whl
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/__init__.py +3 -0
- code_agnostic/__main__.py +275 -0
- code_agnostic/apps.py +71 -0
- code_agnostic/constants.py +19 -0
- code_agnostic/errors.py +29 -0
- code_agnostic/executor.py +158 -0
- code_agnostic/mappers/__init__.py +4 -0
- code_agnostic/mappers/base.py +17 -0
- code_agnostic/mappers/opencode.py +50 -0
- code_agnostic/models.py +179 -0
- code_agnostic/planner.py +212 -0
- code_agnostic/repositories/__init__.py +13 -0
- code_agnostic/repositories/base.py +80 -0
- code_agnostic/repositories/common.py +162 -0
- code_agnostic/repositories/cursor.py +11 -0
- code_agnostic/repositories/opencode.py +83 -0
- code_agnostic/status.py +136 -0
- code_agnostic/tui/__init__.py +3 -0
- code_agnostic/tui/enums.py +24 -0
- code_agnostic/tui/renderers.py +79 -0
- code_agnostic/tui/sections.py +15 -0
- code_agnostic/tui/tables.py +201 -0
- code_agnostic/utils.py +53 -0
- code_agnostic/workspaces.py +45 -0
- code_agnostic-0.1.0.dist-info/METADATA +94 -0
- code_agnostic-0.1.0.dist-info/RECORD +30 -0
- code_agnostic-0.1.0.dist-info/WHEEL +5 -0
- code_agnostic-0.1.0.dist-info/entry_points.txt +2 -0
- code_agnostic-0.1.0.dist-info/licenses/LICENSE +674 -0
- code_agnostic-0.1.0.dist-info/top_level.txt +1 -0
code_agnostic/models.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ActionKind(str, Enum):
|
|
8
|
+
WRITE_JSON = "write_json"
|
|
9
|
+
SYMLINK = "symlink"
|
|
10
|
+
REMOVE_SYMLINK = "remove_symlink"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ActionStatus(str, Enum):
|
|
14
|
+
NOOP = "noop"
|
|
15
|
+
CREATE = "create"
|
|
16
|
+
UPDATE = "update"
|
|
17
|
+
FIX = "fix"
|
|
18
|
+
CONFLICT = "conflict"
|
|
19
|
+
REMOVE = "remove"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SyncTarget(str, Enum):
|
|
23
|
+
ALL = "all"
|
|
24
|
+
OPENCODE = "opencode"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class EditorSyncStatus(str, Enum):
|
|
28
|
+
SYNCED = "synced"
|
|
29
|
+
DRIFT = "drift"
|
|
30
|
+
DISABLED = "disabled"
|
|
31
|
+
ERROR = "error"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class WorkspaceSyncStatus(str, Enum):
|
|
35
|
+
SYNCED = "synced"
|
|
36
|
+
DRIFT = "drift"
|
|
37
|
+
ERROR = "error"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class RepoSyncStatus(str, Enum):
|
|
41
|
+
SYNCED = "synced"
|
|
42
|
+
NEEDS_SYNC = "needs_sync"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AppId(str, Enum):
|
|
46
|
+
OPENCODE = "opencode"
|
|
47
|
+
CURSOR = "cursor"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AppSyncStatus(str, Enum):
|
|
51
|
+
ENABLED = "enabled"
|
|
52
|
+
DISABLED = "disabled"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class Action:
|
|
57
|
+
kind: ActionKind
|
|
58
|
+
path: Path
|
|
59
|
+
status: ActionStatus
|
|
60
|
+
detail: str
|
|
61
|
+
source: Optional[Path] = None
|
|
62
|
+
payload: Optional[Any] = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class SyncPlan:
|
|
67
|
+
actions: list[Action]
|
|
68
|
+
errors: list[Exception]
|
|
69
|
+
skipped: list[str]
|
|
70
|
+
|
|
71
|
+
def is_valid(self) -> bool:
|
|
72
|
+
return not self.errors
|
|
73
|
+
|
|
74
|
+
def summary(self) -> dict[str, int]:
|
|
75
|
+
counts = {status.value: 0 for status in ActionStatus}
|
|
76
|
+
for action in self.actions:
|
|
77
|
+
counts[action.status.value] += 1
|
|
78
|
+
counts["actions"] = len(self.actions)
|
|
79
|
+
counts["errors"] = len(self.errors)
|
|
80
|
+
counts["skipped"] = len(self.skipped)
|
|
81
|
+
return counts
|
|
82
|
+
|
|
83
|
+
def filter_for_target(self, target: SyncTarget, config_path: Path, skills_root: Path, agents_root: Path) -> "SyncPlan":
|
|
84
|
+
if target == SyncTarget.ALL:
|
|
85
|
+
return self
|
|
86
|
+
if target != SyncTarget.OPENCODE:
|
|
87
|
+
return SyncPlan(actions=[], errors=self.errors, skipped=self.skipped)
|
|
88
|
+
|
|
89
|
+
resolved_skills = skills_root.resolve()
|
|
90
|
+
resolved_agents = agents_root.resolve()
|
|
91
|
+
filtered_actions: list[Action] = []
|
|
92
|
+
for action in self.actions:
|
|
93
|
+
if action.path == config_path:
|
|
94
|
+
filtered_actions.append(action)
|
|
95
|
+
continue
|
|
96
|
+
try:
|
|
97
|
+
resolved_path = action.path.resolve()
|
|
98
|
+
except Exception:
|
|
99
|
+
resolved_path = action.path
|
|
100
|
+
if _is_under(resolved_path, resolved_skills) or _is_under(resolved_path, resolved_agents):
|
|
101
|
+
filtered_actions.append(action)
|
|
102
|
+
return SyncPlan(actions=filtered_actions, errors=self.errors, skipped=self.skipped)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
PlanResult = SyncPlan
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class WorkspaceConfig:
|
|
110
|
+
name: str
|
|
111
|
+
path: Path
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True)
|
|
115
|
+
class EditorStatusRow:
|
|
116
|
+
name: str
|
|
117
|
+
status: EditorSyncStatus
|
|
118
|
+
detail: str
|
|
119
|
+
|
|
120
|
+
def as_dict(self) -> dict[str, str]:
|
|
121
|
+
return {
|
|
122
|
+
"name": self.name,
|
|
123
|
+
"status": self.status.value,
|
|
124
|
+
"detail": self.detail,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True)
|
|
129
|
+
class WorkspaceRepoStatusRow:
|
|
130
|
+
repo: str
|
|
131
|
+
status: RepoSyncStatus
|
|
132
|
+
detail: str
|
|
133
|
+
|
|
134
|
+
def as_dict(self) -> dict[str, str]:
|
|
135
|
+
return {
|
|
136
|
+
"repo": self.repo,
|
|
137
|
+
"status": self.status.value,
|
|
138
|
+
"detail": self.detail,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass(frozen=True)
|
|
143
|
+
class WorkspaceStatusRow:
|
|
144
|
+
name: str
|
|
145
|
+
path: str
|
|
146
|
+
status: WorkspaceSyncStatus
|
|
147
|
+
detail: str
|
|
148
|
+
repos: list[WorkspaceRepoStatusRow]
|
|
149
|
+
|
|
150
|
+
def as_dict(self) -> dict[str, Any]:
|
|
151
|
+
return {
|
|
152
|
+
"name": self.name,
|
|
153
|
+
"path": self.path,
|
|
154
|
+
"status": self.status.value,
|
|
155
|
+
"detail": self.detail,
|
|
156
|
+
"repos": [repo.as_dict() for repo in self.repos],
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass(frozen=True)
|
|
161
|
+
class AppStatusRow:
|
|
162
|
+
name: AppId
|
|
163
|
+
status: AppSyncStatus
|
|
164
|
+
detail: str
|
|
165
|
+
|
|
166
|
+
def as_dict(self) -> dict[str, str]:
|
|
167
|
+
return {
|
|
168
|
+
"name": self.name.value,
|
|
169
|
+
"status": self.status.value,
|
|
170
|
+
"detail": self.detail,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _is_under(path: Path, root: Path) -> bool:
|
|
175
|
+
try:
|
|
176
|
+
path.resolve().relative_to(root.resolve())
|
|
177
|
+
return True
|
|
178
|
+
except Exception:
|
|
179
|
+
return False
|
code_agnostic/planner.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from code_agnostic.constants import AGENTS_FILENAME, WORKSPACE_RULE_FILES_DISPLAY
|
|
6
|
+
from code_agnostic.errors import SyncAppError
|
|
7
|
+
from code_agnostic.mappers.base import IConfigMapper
|
|
8
|
+
from code_agnostic.mappers.opencode import OpenCodeMapper
|
|
9
|
+
from code_agnostic.models import Action, ActionKind, ActionStatus, SyncPlan, SyncTarget
|
|
10
|
+
from code_agnostic.repositories.base import ISourceRepository, ITargetRepository
|
|
11
|
+
from code_agnostic.utils import is_under, same_json
|
|
12
|
+
from code_agnostic.workspaces import WorkspaceService
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _canonical_target(path: Path) -> str:
|
|
16
|
+
return str(path.resolve())
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SyncPlanner:
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
common: ISourceRepository,
|
|
23
|
+
opencode: ITargetRepository,
|
|
24
|
+
mapper: Optional[IConfigMapper] = None,
|
|
25
|
+
workspace_service: Optional[WorkspaceService] = None,
|
|
26
|
+
) -> None:
|
|
27
|
+
self.common = common
|
|
28
|
+
self.opencode = opencode
|
|
29
|
+
self.mapper = mapper or OpenCodeMapper()
|
|
30
|
+
self.workspace_service = workspace_service or WorkspaceService()
|
|
31
|
+
|
|
32
|
+
self.actions: list[Action] = []
|
|
33
|
+
self.errors: list[Exception] = []
|
|
34
|
+
self.skipped: list[str] = []
|
|
35
|
+
|
|
36
|
+
self._desired_skill_links: list[Path] = []
|
|
37
|
+
self._desired_agent_links: list[Path] = []
|
|
38
|
+
self._desired_workspace_links: list[Path] = []
|
|
39
|
+
|
|
40
|
+
def build(self) -> SyncPlan:
|
|
41
|
+
self._plan_opencode_config()
|
|
42
|
+
self._plan_skills_links()
|
|
43
|
+
self._plan_agents_links()
|
|
44
|
+
self._plan_workspace_links()
|
|
45
|
+
self._plan_stale_cleanup()
|
|
46
|
+
return SyncPlan(actions=self.actions, errors=self.errors, skipped=self.skipped)
|
|
47
|
+
|
|
48
|
+
def _plan_opencode_config(self) -> None:
|
|
49
|
+
try:
|
|
50
|
+
mcp_base = self.common.load_mcp_base()
|
|
51
|
+
opencode_base = self.common.load_opencode_base()
|
|
52
|
+
except SyncAppError as exc:
|
|
53
|
+
self.errors.append(exc)
|
|
54
|
+
return
|
|
55
|
+
mapped_mcp = self.mapper.map_mcp_servers(mcp_base["mcpServers"])
|
|
56
|
+
|
|
57
|
+
existing_config, config_error = self.opencode.load_config_object()
|
|
58
|
+
if config_error is not None:
|
|
59
|
+
self.errors.append(config_error)
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
merged_config = self.opencode.merge_config(existing_config, opencode_base, mapped_mcp)
|
|
63
|
+
if same_json(self.opencode.config_path, merged_config):
|
|
64
|
+
self.actions.append(
|
|
65
|
+
Action(
|
|
66
|
+
ActionKind.WRITE_JSON,
|
|
67
|
+
self.opencode.config_path,
|
|
68
|
+
ActionStatus.NOOP,
|
|
69
|
+
"already in sync",
|
|
70
|
+
payload=merged_config,
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
status = ActionStatus.CREATE if not self.opencode.config_path.exists() else ActionStatus.UPDATE
|
|
76
|
+
self.actions.append(
|
|
77
|
+
Action(
|
|
78
|
+
ActionKind.WRITE_JSON,
|
|
79
|
+
self.opencode.config_path,
|
|
80
|
+
status,
|
|
81
|
+
"merge opencode base + canonical mcp",
|
|
82
|
+
payload=merged_config,
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def _plan_skills_links(self) -> None:
|
|
87
|
+
skill_sources = self.common.list_skill_sources()
|
|
88
|
+
mapped_skill_sources = [self.mapper.map_skill_source(source) for source in skill_sources]
|
|
89
|
+
self._desired_skill_links = [self.opencode.skills_dir / source.name for source in mapped_skill_sources]
|
|
90
|
+
for source in mapped_skill_sources:
|
|
91
|
+
action = self._plan_symlink(self.opencode.skills_dir / source.name, source)
|
|
92
|
+
self.actions.append(action)
|
|
93
|
+
if action.status == ActionStatus.CONFLICT:
|
|
94
|
+
self.skipped.append(f"Skill link skipped (conflict): {action.path}")
|
|
95
|
+
|
|
96
|
+
def _plan_agents_links(self) -> None:
|
|
97
|
+
agent_sources = self.common.list_agent_sources()
|
|
98
|
+
mapped_agent_sources = [self.mapper.map_agent_source(source) for source in agent_sources]
|
|
99
|
+
self._desired_agent_links = [self.opencode.agents_dir / source.name for source in mapped_agent_sources]
|
|
100
|
+
for source in mapped_agent_sources:
|
|
101
|
+
action = self._plan_symlink(self.opencode.agents_dir / source.name, source)
|
|
102
|
+
self.actions.append(action)
|
|
103
|
+
if action.status == ActionStatus.CONFLICT:
|
|
104
|
+
self.skipped.append(f"Agent link skipped (conflict): {action.path}")
|
|
105
|
+
|
|
106
|
+
def _plan_workspace_links(self) -> None:
|
|
107
|
+
for workspace in self.common.load_workspaces():
|
|
108
|
+
workspace_name = workspace["name"]
|
|
109
|
+
workspace_path = Path(workspace["path"])
|
|
110
|
+
if not workspace_path.exists() or not workspace_path.is_dir():
|
|
111
|
+
self.skipped.append(f"Workspace path missing, skipped: {workspace_name} ({workspace_path})")
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
rules_file = self.workspace_service.resolve_rules_file(workspace_path)
|
|
115
|
+
if rules_file is None:
|
|
116
|
+
self.skipped.append(
|
|
117
|
+
f"Workspace has no rules file ({WORKSPACE_RULE_FILES_DISPLAY}), skipped: {workspace_name}"
|
|
118
|
+
)
|
|
119
|
+
continue
|
|
120
|
+
mapped_rules_file = self.mapper.map_workspace_rules_source(rules_file)
|
|
121
|
+
|
|
122
|
+
for repo in self.workspace_service.discover_git_repos(workspace_path):
|
|
123
|
+
target = repo / AGENTS_FILENAME
|
|
124
|
+
action = self._plan_symlink(target, mapped_rules_file)
|
|
125
|
+
self.actions.append(action)
|
|
126
|
+
self._desired_workspace_links.append(target)
|
|
127
|
+
if action.status == ActionStatus.CONFLICT:
|
|
128
|
+
self.skipped.append(f"Workspace rules link skipped (conflict): {target}")
|
|
129
|
+
|
|
130
|
+
def _plan_stale_cleanup(self) -> None:
|
|
131
|
+
state = self.common.load_state()
|
|
132
|
+
opencode_roots = [self.opencode.skills_dir.resolve(), self.opencode.agents_dir.resolve()]
|
|
133
|
+
|
|
134
|
+
self._plan_stale_group(
|
|
135
|
+
[Path(item) for item in state.get("managed_skill_links", []) if isinstance(item, str)],
|
|
136
|
+
self._desired_skill_links,
|
|
137
|
+
"remove stale managed skill symlink",
|
|
138
|
+
"stale managed path is not a symlink",
|
|
139
|
+
"stale symlink already absent",
|
|
140
|
+
"Stale link cleanup skipped (not symlink): {path}",
|
|
141
|
+
enforce_under_roots=opencode_roots,
|
|
142
|
+
)
|
|
143
|
+
self._plan_stale_group(
|
|
144
|
+
[Path(item) for item in state.get("managed_agent_links", []) if isinstance(item, str)],
|
|
145
|
+
self._desired_agent_links,
|
|
146
|
+
"remove stale managed agent symlink",
|
|
147
|
+
"stale managed path is not a symlink",
|
|
148
|
+
"stale symlink already absent",
|
|
149
|
+
"Stale link cleanup skipped (not symlink): {path}",
|
|
150
|
+
enforce_under_roots=opencode_roots,
|
|
151
|
+
)
|
|
152
|
+
self._plan_stale_group(
|
|
153
|
+
[Path(item) for item in state.get("managed_workspace_links", []) if isinstance(item, str)],
|
|
154
|
+
self._desired_workspace_links,
|
|
155
|
+
"remove stale managed workspace symlink",
|
|
156
|
+
"stale workspace path is not a symlink",
|
|
157
|
+
"stale workspace symlink already absent",
|
|
158
|
+
"Stale workspace cleanup skipped (not symlink): {path}",
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def _plan_stale_group(
|
|
162
|
+
self,
|
|
163
|
+
old_links: list[Path],
|
|
164
|
+
desired_links: list[Path],
|
|
165
|
+
remove_detail: str,
|
|
166
|
+
conflict_detail: str,
|
|
167
|
+
noop_detail: str,
|
|
168
|
+
skipped_message: str,
|
|
169
|
+
enforce_under_roots: Optional[list[Path]] = None,
|
|
170
|
+
) -> None:
|
|
171
|
+
desired = {str(path) for path in desired_links}
|
|
172
|
+
for old in old_links:
|
|
173
|
+
if str(old) in desired:
|
|
174
|
+
continue
|
|
175
|
+
if enforce_under_roots and not any(is_under(old, root) for root in enforce_under_roots):
|
|
176
|
+
continue
|
|
177
|
+
if old.is_symlink():
|
|
178
|
+
self.actions.append(Action(ActionKind.REMOVE_SYMLINK, old, ActionStatus.REMOVE, remove_detail))
|
|
179
|
+
elif old.exists():
|
|
180
|
+
self.actions.append(Action(ActionKind.REMOVE_SYMLINK, old, ActionStatus.CONFLICT, conflict_detail))
|
|
181
|
+
self.skipped.append(skipped_message.format(path=old))
|
|
182
|
+
else:
|
|
183
|
+
self.actions.append(Action(ActionKind.REMOVE_SYMLINK, old, ActionStatus.NOOP, noop_detail))
|
|
184
|
+
|
|
185
|
+
@staticmethod
|
|
186
|
+
def _plan_symlink(target: Path, source: Path) -> Action:
|
|
187
|
+
desired = _canonical_target(source)
|
|
188
|
+
if target.exists() or target.is_symlink():
|
|
189
|
+
if target.is_symlink():
|
|
190
|
+
current = os.path.realpath(target)
|
|
191
|
+
if current == desired:
|
|
192
|
+
return Action(ActionKind.SYMLINK, target, ActionStatus.NOOP, "already linked", source=source)
|
|
193
|
+
return Action(ActionKind.SYMLINK, target, ActionStatus.FIX, "symlink points elsewhere", source=source)
|
|
194
|
+
return Action(ActionKind.SYMLINK, target, ActionStatus.CONFLICT, "non-symlink path exists", source=source)
|
|
195
|
+
return Action(ActionKind.SYMLINK, target, ActionStatus.CREATE, "create symlink", source=source)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def build_plan(common: ISourceRepository, opencode: ITargetRepository, mapper: Optional[IConfigMapper] = None) -> SyncPlan:
|
|
199
|
+
return SyncPlanner(common=common, opencode=opencode, mapper=mapper).build()
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def filter_plan_for_target(plan: SyncPlan, target: str, opencode: ITargetRepository) -> SyncPlan:
|
|
203
|
+
try:
|
|
204
|
+
normalized_target = SyncTarget(target)
|
|
205
|
+
except ValueError:
|
|
206
|
+
return SyncPlan(actions=[], errors=plan.errors, skipped=plan.skipped)
|
|
207
|
+
return plan.filter_for_target(
|
|
208
|
+
target=normalized_target,
|
|
209
|
+
config_path=opencode.config_path,
|
|
210
|
+
skills_root=opencode.skills_dir,
|
|
211
|
+
agents_root=opencode.agents_dir,
|
|
212
|
+
)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from code_agnostic.repositories.base import IConfigRepository, ISourceRepository, ITargetRepository
|
|
2
|
+
from code_agnostic.repositories.common import CommonRepository
|
|
3
|
+
from code_agnostic.repositories.cursor import CursorRepository
|
|
4
|
+
from code_agnostic.repositories.opencode import OpenCodeRepository
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"IConfigRepository",
|
|
8
|
+
"ISourceRepository",
|
|
9
|
+
"ITargetRepository",
|
|
10
|
+
"CommonRepository",
|
|
11
|
+
"CursorRepository",
|
|
12
|
+
"OpenCodeRepository",
|
|
13
|
+
]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any, Optional, Tuple
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class IConfigRepository(ABC):
|
|
7
|
+
@property
|
|
8
|
+
@abstractmethod
|
|
9
|
+
def root(self) -> Path:
|
|
10
|
+
raise NotImplementedError
|
|
11
|
+
|
|
12
|
+
@abstractmethod
|
|
13
|
+
def load_state(self) -> dict[str, Any]:
|
|
14
|
+
raise NotImplementedError
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
def save_state(self, data: dict[str, Any]) -> None:
|
|
18
|
+
raise NotImplementedError
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ISourceRepository(IConfigRepository):
|
|
22
|
+
@property
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def skills_dir(self) -> Path:
|
|
25
|
+
raise NotImplementedError
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def agents_dir(self) -> Path:
|
|
30
|
+
raise NotImplementedError
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
def load_mcp_base(self) -> dict[str, Any]:
|
|
34
|
+
raise NotImplementedError
|
|
35
|
+
|
|
36
|
+
@abstractmethod
|
|
37
|
+
def load_opencode_base(self) -> dict[str, Any]:
|
|
38
|
+
raise NotImplementedError
|
|
39
|
+
|
|
40
|
+
@abstractmethod
|
|
41
|
+
def list_skill_sources(self) -> list[Path]:
|
|
42
|
+
raise NotImplementedError
|
|
43
|
+
|
|
44
|
+
@abstractmethod
|
|
45
|
+
def list_agent_sources(self) -> list[Path]:
|
|
46
|
+
raise NotImplementedError
|
|
47
|
+
|
|
48
|
+
@abstractmethod
|
|
49
|
+
def load_workspaces(self) -> list[dict[str, str]]:
|
|
50
|
+
raise NotImplementedError
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ITargetRepository(ABC):
|
|
54
|
+
@property
|
|
55
|
+
@abstractmethod
|
|
56
|
+
def root(self) -> Path:
|
|
57
|
+
raise NotImplementedError
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
@abstractmethod
|
|
61
|
+
def config_path(self) -> Path:
|
|
62
|
+
raise NotImplementedError
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
@abstractmethod
|
|
66
|
+
def skills_dir(self) -> Path:
|
|
67
|
+
raise NotImplementedError
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
@abstractmethod
|
|
71
|
+
def agents_dir(self) -> Path:
|
|
72
|
+
raise NotImplementedError
|
|
73
|
+
|
|
74
|
+
@abstractmethod
|
|
75
|
+
def load_config_object(self) -> Tuple[dict[str, Any], Optional[Exception]]:
|
|
76
|
+
raise NotImplementedError
|
|
77
|
+
|
|
78
|
+
@abstractmethod
|
|
79
|
+
def merge_config(self, existing: dict[str, Any], base: dict[str, Any], mapped_mcp: dict[str, Any]) -> dict[str, Any]:
|
|
80
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Any, Optional
|
|
3
|
+
|
|
4
|
+
from code_agnostic.errors import InvalidConfigSchemaError, InvalidJsonFormatError, MissingConfigFileError
|
|
5
|
+
from code_agnostic.repositories.base import ISourceRepository
|
|
6
|
+
from code_agnostic.utils import read_json_safe, write_json
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CommonRepository(ISourceRepository):
|
|
10
|
+
def __init__(self, root: Optional[Path] = None) -> None:
|
|
11
|
+
self._root = root or (Path.home() / ".config" / "code-agnostic")
|
|
12
|
+
|
|
13
|
+
@property
|
|
14
|
+
def root(self) -> Path:
|
|
15
|
+
return self._root
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def config_dir(self) -> Path:
|
|
19
|
+
return self.root / "config"
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def skills_dir(self) -> Path:
|
|
23
|
+
return self.root / "skills"
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def agents_dir(self) -> Path:
|
|
27
|
+
return self.root / "agents"
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def state_json(self) -> Path:
|
|
31
|
+
return self.root / ".sync-state.json"
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def mcp_base_path(self) -> Path:
|
|
35
|
+
return self.config_dir / "mcp.base.json"
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def opencode_base_path(self) -> Path:
|
|
39
|
+
return self.config_dir / "opencode.base.json"
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def workspaces_path(self) -> Path:
|
|
43
|
+
return self.config_dir / "workspaces.json"
|
|
44
|
+
|
|
45
|
+
def load_mcp_base(self) -> dict[str, Any]:
|
|
46
|
+
if not self.mcp_base_path.exists():
|
|
47
|
+
raise MissingConfigFileError(self.mcp_base_path)
|
|
48
|
+
payload, error = read_json_safe(self.mcp_base_path)
|
|
49
|
+
if error is not None:
|
|
50
|
+
raise InvalidJsonFormatError(self.mcp_base_path, error)
|
|
51
|
+
if not isinstance(payload, dict) or not isinstance(payload.get("mcpServers"), dict):
|
|
52
|
+
raise InvalidConfigSchemaError(self.mcp_base_path, "must contain object key 'mcpServers'")
|
|
53
|
+
return payload
|
|
54
|
+
|
|
55
|
+
def load_opencode_base(self) -> dict[str, Any]:
|
|
56
|
+
if not self.opencode_base_path.exists():
|
|
57
|
+
raise MissingConfigFileError(self.opencode_base_path)
|
|
58
|
+
payload, error = read_json_safe(self.opencode_base_path)
|
|
59
|
+
if error is not None:
|
|
60
|
+
raise InvalidJsonFormatError(self.opencode_base_path, error)
|
|
61
|
+
if not isinstance(payload, dict):
|
|
62
|
+
raise InvalidConfigSchemaError(self.opencode_base_path, "must be a JSON object")
|
|
63
|
+
return payload
|
|
64
|
+
|
|
65
|
+
def list_skill_sources(self) -> list[Path]:
|
|
66
|
+
if not self.skills_dir.exists():
|
|
67
|
+
return []
|
|
68
|
+
result: list[Path] = []
|
|
69
|
+
for child in sorted(self.skills_dir.iterdir()):
|
|
70
|
+
if child.is_dir() and (child / "SKILL.md").exists():
|
|
71
|
+
result.append(child)
|
|
72
|
+
return result
|
|
73
|
+
|
|
74
|
+
def list_agent_sources(self) -> list[Path]:
|
|
75
|
+
if not self.agents_dir.exists():
|
|
76
|
+
return []
|
|
77
|
+
result: list[Path] = []
|
|
78
|
+
for child in sorted(self.agents_dir.iterdir()):
|
|
79
|
+
if child.name.startswith("."):
|
|
80
|
+
continue
|
|
81
|
+
result.append(child)
|
|
82
|
+
return result
|
|
83
|
+
|
|
84
|
+
def load_state(self) -> dict[str, Any]:
|
|
85
|
+
payload, error = read_json_safe(self.state_json)
|
|
86
|
+
if error is not None or not isinstance(payload, dict):
|
|
87
|
+
return {
|
|
88
|
+
"managed_skill_links": [],
|
|
89
|
+
"managed_agent_links": [],
|
|
90
|
+
"managed_workspace_links": [],
|
|
91
|
+
}
|
|
92
|
+
payload.setdefault("managed_skill_links", [])
|
|
93
|
+
payload.setdefault("managed_agent_links", [])
|
|
94
|
+
payload.setdefault("managed_workspace_links", [])
|
|
95
|
+
if not isinstance(payload["managed_skill_links"], list):
|
|
96
|
+
payload["managed_skill_links"] = []
|
|
97
|
+
if not isinstance(payload["managed_agent_links"], list):
|
|
98
|
+
payload["managed_agent_links"] = []
|
|
99
|
+
if not isinstance(payload["managed_workspace_links"], list):
|
|
100
|
+
payload["managed_workspace_links"] = []
|
|
101
|
+
return payload
|
|
102
|
+
|
|
103
|
+
def save_state(self, data: dict[str, Any]) -> None:
|
|
104
|
+
write_json(self.state_json, data)
|
|
105
|
+
|
|
106
|
+
def load_workspaces(self) -> list[dict[str, str]]:
|
|
107
|
+
payload, error = read_json_safe(self.workspaces_path)
|
|
108
|
+
if error is not None or payload is None:
|
|
109
|
+
return []
|
|
110
|
+
if not isinstance(payload, list):
|
|
111
|
+
return []
|
|
112
|
+
|
|
113
|
+
result: list[dict[str, str]] = []
|
|
114
|
+
seen_names: set[str] = set()
|
|
115
|
+
for item in payload:
|
|
116
|
+
if not isinstance(item, dict):
|
|
117
|
+
continue
|
|
118
|
+
name = item.get("name")
|
|
119
|
+
path = item.get("path")
|
|
120
|
+
if not isinstance(name, str) or not isinstance(path, str):
|
|
121
|
+
continue
|
|
122
|
+
normalized_name = name.strip()
|
|
123
|
+
normalized_path = str(Path(path).expanduser().resolve())
|
|
124
|
+
if not normalized_name or normalized_name in seen_names:
|
|
125
|
+
continue
|
|
126
|
+
result.append({"name": normalized_name, "path": normalized_path})
|
|
127
|
+
seen_names.add(normalized_name)
|
|
128
|
+
return result
|
|
129
|
+
|
|
130
|
+
def save_workspaces(self, workspaces: list[dict[str, str]]) -> None:
|
|
131
|
+
serialized = sorted(
|
|
132
|
+
[{"name": item["name"], "path": item["path"]} for item in workspaces],
|
|
133
|
+
key=lambda item: item["name"].lower(),
|
|
134
|
+
)
|
|
135
|
+
write_json(self.workspaces_path, serialized)
|
|
136
|
+
|
|
137
|
+
def add_workspace(self, name: str, path: Path) -> None:
|
|
138
|
+
normalized_name = name.strip()
|
|
139
|
+
if not normalized_name:
|
|
140
|
+
raise ValueError("Workspace name cannot be empty")
|
|
141
|
+
normalized_path = path.expanduser().resolve()
|
|
142
|
+
if not normalized_path.exists() or not normalized_path.is_dir():
|
|
143
|
+
raise ValueError(f"Workspace path does not exist or is not a directory: {normalized_path}")
|
|
144
|
+
|
|
145
|
+
workspaces = self.load_workspaces()
|
|
146
|
+
for item in workspaces:
|
|
147
|
+
if item["name"] == normalized_name:
|
|
148
|
+
raise ValueError(f"Workspace name already exists: {normalized_name}")
|
|
149
|
+
if Path(item["path"]) == normalized_path:
|
|
150
|
+
raise ValueError(f"Workspace path already exists: {normalized_path}")
|
|
151
|
+
|
|
152
|
+
workspaces.append({"name": normalized_name, "path": str(normalized_path)})
|
|
153
|
+
self.save_workspaces(workspaces)
|
|
154
|
+
|
|
155
|
+
def remove_workspace(self, name: str) -> bool:
|
|
156
|
+
target_name = name.strip()
|
|
157
|
+
workspaces = self.load_workspaces()
|
|
158
|
+
kept = [item for item in workspaces if item["name"] != target_name]
|
|
159
|
+
if len(kept) == len(workspaces):
|
|
160
|
+
return False
|
|
161
|
+
self.save_workspaces(kept)
|
|
162
|
+
return True
|