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.
@@ -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
@@ -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
@@ -0,0 +1,11 @@
1
+ from pathlib import Path
2
+ from typing import Optional
3
+
4
+
5
+ class CursorRepository:
6
+ def __init__(self, root: Optional[Path] = None) -> None:
7
+ self._root = root or (Path.home() / ".cursor")
8
+
9
+ @property
10
+ def root(self) -> Path:
11
+ return self._root