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,83 @@
1
+ from copy import deepcopy
2
+ from pathlib import Path
3
+ from typing import Any, Optional, Tuple
4
+
5
+ from code_agnostic.errors import InvalidConfigSchemaError, InvalidJsonFormatError
6
+ from code_agnostic.repositories.base import ITargetRepository
7
+ from code_agnostic.utils import read_json_safe
8
+
9
+
10
+ class OpenCodeRepository(ITargetRepository):
11
+ def __init__(self, root: Optional[Path] = None) -> None:
12
+ self._root = root or (Path.home() / ".config" / "opencode")
13
+
14
+ @property
15
+ def root(self) -> Path:
16
+ return self._root
17
+
18
+ @property
19
+ def config_path(self) -> Path:
20
+ return self.root / "opencode.json"
21
+
22
+ @property
23
+ def skills_dir(self) -> Path:
24
+ return self.root / "skills"
25
+
26
+ @property
27
+ def agents_dir(self) -> Path:
28
+ plural = self.root / "agents"
29
+ singular = self.root / "agent"
30
+ if plural.exists():
31
+ return plural
32
+ if singular.exists():
33
+ return singular
34
+ return plural
35
+
36
+ def load_config_object(self) -> Tuple[dict[str, Any], Optional[Exception]]:
37
+ payload, error = read_json_safe(self.config_path)
38
+ if error is not None:
39
+ return {}, InvalidJsonFormatError(self.config_path, error)
40
+ if payload is None:
41
+ return {}, None
42
+ if not isinstance(payload, dict):
43
+ return {}, InvalidConfigSchemaError(self.config_path, "must be a JSON object")
44
+ return payload, None
45
+
46
+ @staticmethod
47
+ def _migrate_legacy_permission(base: dict[str, Any], merged: dict[str, Any]) -> None:
48
+ permission = base.get("permission")
49
+ if not isinstance(permission, list):
50
+ return
51
+
52
+ tools = merged.get("tools")
53
+ if not isinstance(tools, dict):
54
+ tools = {}
55
+
56
+ for rule in permission:
57
+ if not isinstance(rule, dict):
58
+ continue
59
+ permission_name = rule.get("permission")
60
+ action = rule.get("action")
61
+ if not isinstance(permission_name, str) or not isinstance(action, str):
62
+ continue
63
+ if permission_name == "*":
64
+ continue
65
+ if action == "deny":
66
+ tools[permission_name] = False
67
+ elif action == "allow":
68
+ tools[permission_name] = True
69
+
70
+ if tools:
71
+ merged["tools"] = tools
72
+
73
+ merged.pop("permission", None)
74
+
75
+ def merge_config(self, existing: dict[str, Any], base: dict[str, Any], mapped_mcp: dict[str, Any]) -> dict[str, Any]:
76
+ merged = deepcopy(existing)
77
+ self._migrate_legacy_permission(base, merged)
78
+ for key, value in base.items():
79
+ if key == "permission" and isinstance(value, list):
80
+ continue
81
+ merged[key] = deepcopy(value)
82
+ merged["mcp"] = deepcopy(mapped_mcp)
83
+ return merged
@@ -0,0 +1,136 @@
1
+ from pathlib import Path
2
+ from typing import Any, Optional
3
+
4
+ from code_agnostic.constants import AGENTS_FILENAME
5
+ from code_agnostic.models import (
6
+ ActionStatus,
7
+ EditorStatusRow,
8
+ EditorSyncStatus,
9
+ RepoSyncStatus,
10
+ SyncPlan,
11
+ WorkspaceRepoStatusRow,
12
+ WorkspaceStatusRow,
13
+ WorkspaceSyncStatus,
14
+ )
15
+ from code_agnostic.repositories.base import ISourceRepository, ITargetRepository
16
+ from code_agnostic.utils import is_under
17
+ from code_agnostic.workspaces import WorkspaceService
18
+
19
+
20
+ class StatusService:
21
+ def __init__(self, workspace_service: Optional[WorkspaceService] = None) -> None:
22
+ self.workspace_service = workspace_service or WorkspaceService()
23
+
24
+ def build_editor_status(self, plan: SyncPlan, target_repo: ITargetRepository) -> list[EditorStatusRow]:
25
+ opencode_actions = self._opencode_actions(plan, target_repo)
26
+ opencode_synced = self._synced_from_actions(opencode_actions)
27
+
28
+ return [
29
+ EditorStatusRow(
30
+ name="opencode",
31
+ status=EditorSyncStatus.SYNCED if opencode_synced else EditorSyncStatus.DRIFT,
32
+ detail="in sync" if opencode_synced else "out of sync",
33
+ ),
34
+ EditorStatusRow(
35
+ name="cursor",
36
+ status=EditorSyncStatus.DISABLED,
37
+ detail="not managed",
38
+ ),
39
+ ]
40
+
41
+ def build_workspace_status(self, source_repo: ISourceRepository) -> list[WorkspaceStatusRow]:
42
+ status_rows: list[WorkspaceStatusRow] = []
43
+
44
+ for workspace in source_repo.load_workspaces():
45
+ workspace_name = workspace["name"]
46
+ workspace_path = Path(workspace["path"])
47
+
48
+ if not workspace_path.exists() or not workspace_path.is_dir():
49
+ status_rows.append(
50
+ WorkspaceStatusRow(
51
+ name=workspace_name,
52
+ path=str(workspace_path),
53
+ status=WorkspaceSyncStatus.ERROR,
54
+ detail="workspace path missing",
55
+ repos=[],
56
+ )
57
+ )
58
+ continue
59
+
60
+ rules_file = self.workspace_service.resolve_rules_file(workspace_path)
61
+ if rules_file is None:
62
+ status_rows.append(
63
+ WorkspaceStatusRow(
64
+ name=workspace_name,
65
+ path=str(workspace_path),
66
+ status=WorkspaceSyncStatus.ERROR,
67
+ detail="no workspace rules file",
68
+ repos=[],
69
+ )
70
+ )
71
+ continue
72
+
73
+ repos = self.workspace_service.discover_git_repos(workspace_path)
74
+ repo_rows = [self._repo_sync_status(repo, rules_file) for repo in repos]
75
+
76
+ detail = "all git repos synced"
77
+ status = WorkspaceSyncStatus.SYNCED
78
+ if not repos:
79
+ detail = "no git repos found"
80
+ elif any(item.status != RepoSyncStatus.SYNCED for item in repo_rows):
81
+ status = WorkspaceSyncStatus.DRIFT
82
+ detail = "one or more repos need sync"
83
+
84
+ status_rows.append(
85
+ WorkspaceStatusRow(
86
+ name=workspace_name,
87
+ path=str(workspace_path),
88
+ status=status,
89
+ detail=detail,
90
+ repos=repo_rows,
91
+ )
92
+ )
93
+
94
+ return status_rows
95
+
96
+ @staticmethod
97
+ def _opencode_actions(plan: SyncPlan, target_repo: ITargetRepository) -> list[Any]:
98
+ skills_root = target_repo.skills_dir.resolve()
99
+ agents_root = target_repo.agents_dir.resolve()
100
+
101
+ relevant = []
102
+ for action in plan.actions:
103
+ if action.path == target_repo.config_path:
104
+ relevant.append(action)
105
+ continue
106
+ if is_under(action.path, skills_root) or is_under(action.path, agents_root):
107
+ relevant.append(action)
108
+ return relevant
109
+
110
+ @staticmethod
111
+ def _synced_from_actions(actions: list[Any]) -> bool:
112
+ if not actions:
113
+ return True
114
+ return all(action.status == ActionStatus.NOOP for action in actions)
115
+
116
+ @staticmethod
117
+ def _repo_sync_status(repo_path: Path, rules_file: Path) -> WorkspaceRepoStatusRow:
118
+ target = repo_path / AGENTS_FILENAME
119
+ desired = str(rules_file.resolve())
120
+ if target.is_symlink() and str(target.resolve()) == desired:
121
+ return WorkspaceRepoStatusRow(repo=repo_path.name, status=RepoSyncStatus.SYNCED, detail="linked")
122
+ return WorkspaceRepoStatusRow(
123
+ repo=repo_path.name,
124
+ status=RepoSyncStatus.NEEDS_SYNC,
125
+ detail=f"missing or mismatched {AGENTS_FILENAME}",
126
+ )
127
+
128
+
129
+ def build_editor_status(plan: SyncPlan, opencode: ITargetRepository) -> list[dict[str, str]]:
130
+ rows = StatusService().build_editor_status(plan=plan, target_repo=opencode)
131
+ return [row.as_dict() for row in rows]
132
+
133
+
134
+ def build_workspace_status(common: ISourceRepository) -> list[dict[str, Any]]:
135
+ rows = StatusService().build_workspace_status(source_repo=common)
136
+ return [row.as_dict() for row in rows]
@@ -0,0 +1,3 @@
1
+ from code_agnostic.tui.renderers import SyncConsoleUI
2
+
3
+ __all__ = ["SyncConsoleUI"]
@@ -0,0 +1,24 @@
1
+ from enum import Enum
2
+
3
+ from code_agnostic.models import ActionStatus
4
+
5
+
6
+ class UIStyle(str, Enum):
7
+ BLUE = "blue"
8
+ GREEN = "green"
9
+ YELLOW = "yellow"
10
+ RED = "red"
11
+ CYAN = "cyan"
12
+ MAGENTA = "magenta"
13
+ DIM = "dim"
14
+ WHITE = "white"
15
+
16
+
17
+ ACTION_STATUS_STYLE = {
18
+ ActionStatus.CREATE: UIStyle.GREEN.value,
19
+ ActionStatus.UPDATE: UIStyle.CYAN.value,
20
+ ActionStatus.FIX: UIStyle.YELLOW.value,
21
+ ActionStatus.REMOVE: UIStyle.MAGENTA.value,
22
+ ActionStatus.NOOP: UIStyle.DIM.value,
23
+ ActionStatus.CONFLICT: UIStyle.RED.value,
24
+ }
@@ -0,0 +1,79 @@
1
+ from typing import List, Optional
2
+
3
+ from rich.console import Console
4
+
5
+ from code_agnostic.models import PlanResult, WorkspaceSyncStatus
6
+ from code_agnostic.tui.enums import UIStyle
7
+ from code_agnostic.tui.sections import UISection
8
+ from code_agnostic.tui.tables import AppsTable, ApplyTable, PlanTable, StatusTable, WorkspaceTable
9
+
10
+
11
+ class SyncConsoleUI:
12
+ def __init__(self, console: Optional[Console] = None) -> None:
13
+ self.console = console or Console()
14
+
15
+ def render_plan(self, plan: PlanResult, mode: str) -> None:
16
+ app_actions, workspace_actions = PlanTable.split_actions(plan)
17
+
18
+ self.console.print(UISection.wrap("plan overview", PlanTable.summary_block(plan, mode=mode), style=UIStyle.BLUE.value))
19
+
20
+ if app_actions:
21
+ self.console.print(
22
+ UISection.wrap("app config sync", PlanTable.actions_table(app_actions), style=UIStyle.CYAN.value)
23
+ )
24
+ if workspace_actions:
25
+ self.console.print(
26
+ UISection.wrap("workspace links", PlanTable.actions_table(workspace_actions), style=UIStyle.MAGENTA.value)
27
+ )
28
+ if not app_actions and not workspace_actions:
29
+ self.console.print(UISection.note("actions", "No actions required.", style=UIStyle.DIM.value))
30
+
31
+ if plan.errors:
32
+ errors_text = "\n".join([f"- {item}" for item in plan.errors])
33
+ self.console.print(UISection.note("errors", errors_text, style=UIStyle.RED.value))
34
+
35
+ if plan.skipped:
36
+ skipped_text = "\n".join([f"- {item}" for item in plan.skipped])
37
+ self.console.print(UISection.note("skipped", skipped_text, style=UIStyle.YELLOW.value))
38
+
39
+ def render_apply_result(self, applied: int, failed: int, failures: List[str]) -> None:
40
+ self.console.print(ApplyTable.stats_panel(applied=applied, failed=failed))
41
+ if failures:
42
+ failure_text = "\n".join([f"- {item}" for item in failures])
43
+ self.console.print(UISection.note("failures", failure_text, style=UIStyle.RED.value))
44
+
45
+ def render_workspace_saved(self, name: str, path: str, removed: bool = False) -> None:
46
+ verb = "removed" if removed else "added"
47
+ border_style = UIStyle.YELLOW.value if removed else UIStyle.GREEN.value
48
+ self.console.print(UISection.note("workspace", f"Workspace {verb}: [bold]{name}[/bold]\n{path}", style=border_style))
49
+
50
+ def render_workspaces_overview(self, items: List[dict]) -> None:
51
+ if not items:
52
+ self.console.print(UISection.note("workspaces", "No workspaces configured.", style=UIStyle.YELLOW.value))
53
+ return
54
+
55
+ self.console.print(UISection.wrap("workspaces", WorkspaceTable.overview_table(items), style=UIStyle.BLUE.value))
56
+ self.console.print(UISection.wrap("workspace repositories", WorkspaceTable.repos_table(items), style=UIStyle.CYAN.value))
57
+
58
+ def render_status(self, editors: List[dict], workspaces: List[dict]) -> None:
59
+ self.console.print(UISection.wrap("app config sync", StatusTable.editor_table(editors), style=UIStyle.BLUE.value))
60
+
61
+ if not workspaces:
62
+ self.console.print(UISection.note("workspace sync", "No workspaces configured.", style=UIStyle.YELLOW.value))
63
+ return
64
+
65
+ workspace_style = UIStyle.GREEN.value
66
+ if any(item.get("status") == WorkspaceSyncStatus.DRIFT.value for item in workspaces):
67
+ workspace_style = UIStyle.YELLOW.value
68
+ if any(item.get("status") == WorkspaceSyncStatus.ERROR.value for item in workspaces):
69
+ workspace_style = UIStyle.RED.value
70
+
71
+ self.console.print(
72
+ UISection.wrap("workspace sync", StatusTable.workspace_overview(workspaces), style=workspace_style)
73
+ )
74
+ self.console.print(
75
+ UISection.wrap("workspace repositories", StatusTable.workspace_repos_group(workspaces), style=UIStyle.CYAN.value)
76
+ )
77
+
78
+ def render_apps(self, items: List[dict]) -> None:
79
+ self.console.print(UISection.wrap("apps", AppsTable.apps_table(items), style=UIStyle.BLUE.value))
@@ -0,0 +1,15 @@
1
+ from typing import Optional
2
+
3
+ from rich.panel import Panel
4
+
5
+ from code_agnostic.tui.enums import UIStyle
6
+
7
+
8
+ class UISection:
9
+ @staticmethod
10
+ def wrap(title: str, body, style: str = UIStyle.BLUE.value, subtitle: Optional[str] = None) -> Panel:
11
+ return Panel(body, title=title, subtitle=subtitle, border_style=style, padding=(0, 1))
12
+
13
+ @staticmethod
14
+ def note(title: str, body: str, style: str) -> Panel:
15
+ return Panel(body, title=title, border_style=style, padding=(0, 1))
@@ -0,0 +1,201 @@
1
+ from collections import Counter
2
+ from typing import Dict, List
3
+
4
+ from rich.console import Group
5
+ from rich.padding import Padding
6
+ from rich.panel import Panel
7
+ from rich.table import Column, Table
8
+ from rich.text import Text
9
+
10
+ from code_agnostic.constants import AGENTS_FILENAME
11
+ from code_agnostic.models import Action, AppSyncStatus, EditorSyncStatus, PlanResult, RepoSyncStatus, WorkspaceSyncStatus
12
+ from code_agnostic.tui.enums import ACTION_STATUS_STYLE, UIStyle
13
+
14
+
15
+ class PlanTable:
16
+ @staticmethod
17
+ def summary_block(plan: PlanResult, mode: str):
18
+ counts = Counter(action.status.value for action in plan.actions)
19
+ chips = [f"{key}={value}" for key, value in sorted(counts.items()) if value > 0]
20
+ if not chips:
21
+ chips = ["none"]
22
+
23
+ table = Table.grid(padding=(0, 2))
24
+ table.add_column(style="bold")
25
+ table.add_column()
26
+ table.add_row("Mode", mode)
27
+ table.add_row("Actions", str(len(plan.actions)))
28
+ table.add_row("Statuses", " ".join(chips))
29
+ return table
30
+
31
+ @staticmethod
32
+ def split_actions(plan: PlanResult) -> tuple[list[Action], list[Action]]:
33
+ app_actions: list[Action] = []
34
+ workspace_actions: list[Action] = []
35
+ for action in plan.actions:
36
+ is_workspace_link = action.path.name == AGENTS_FILENAME
37
+ if is_workspace_link:
38
+ workspace_actions.append(action)
39
+ else:
40
+ app_actions.append(action)
41
+ return app_actions, workspace_actions
42
+
43
+ @staticmethod
44
+ def actions_table(actions: list[Action]) -> Table:
45
+ table = Table(
46
+ Column(header="Type", width=12),
47
+ Column(header="Status", width=10),
48
+ Column(header="Target", overflow="ellipsis", max_width=58),
49
+ Column(header="Source", overflow="ellipsis", max_width=42),
50
+ Column(header="Reason", overflow="ellipsis"),
51
+ expand=True,
52
+ header_style="bold",
53
+ )
54
+
55
+ for action in actions:
56
+ source = str(action.source) if action.source is not None else ""
57
+ status_value = action.status.value
58
+ status_style = ACTION_STATUS_STYLE.get(action.status, UIStyle.WHITE.value)
59
+ status_text = f"[{status_style}]{status_value}[/{status_style}]"
60
+ table.add_row(action.kind.value, status_text, str(action.path), source, action.detail)
61
+ return table
62
+
63
+
64
+ class ApplyTable:
65
+ @staticmethod
66
+ def stats_panel(applied: int, failed: int) -> Panel:
67
+ stats: Dict[str, str] = {
68
+ "applied": str(applied),
69
+ "failed": str(failed),
70
+ }
71
+ table = Table(show_header=False, box=None)
72
+ for key, value in stats.items():
73
+ table.add_row(f"[bold]{key}[/bold]", value)
74
+ return Panel(table, title="apply", border_style=UIStyle.GREEN.value if failed == 0 else UIStyle.RED.value)
75
+
76
+
77
+ class WorkspaceTable:
78
+ @staticmethod
79
+ def overview_table(items: List[dict]) -> Table:
80
+ table = Table(
81
+ Column(header="Workspace", width=24),
82
+ Column(header="Path", overflow="ellipsis"),
83
+ Column(header="Repos", width=8, justify="right"),
84
+ expand=True,
85
+ header_style="bold",
86
+ )
87
+ for item in items:
88
+ table.add_row(item["name"], item["path"], str(len(item["repos"])))
89
+ return table
90
+
91
+ @staticmethod
92
+ def repos_table(items: List[dict]) -> Table:
93
+ table = Table(
94
+ Column(header="Workspace", width=24),
95
+ Column(header="Repositories", overflow="fold"),
96
+ expand=True,
97
+ header_style="bold",
98
+ )
99
+ for item in items:
100
+ repos = item.get("repos", [])
101
+ if not repos:
102
+ repo_line = "(no git repos found)"
103
+ else:
104
+ repo_line = ", ".join(repos)
105
+ table.add_row(item["name"], repo_line)
106
+ return table
107
+
108
+
109
+ class StatusTable:
110
+ @staticmethod
111
+ def editor_table(items: List[dict]) -> Table:
112
+ table = Table(
113
+ "Editor",
114
+ "Status",
115
+ "Detail",
116
+ expand=True,
117
+ header_style="bold",
118
+ )
119
+ for item in items:
120
+ status = item["status"]
121
+ style = (
122
+ UIStyle.GREEN.value
123
+ if status == EditorSyncStatus.SYNCED.value
124
+ else UIStyle.YELLOW.value
125
+ if status == EditorSyncStatus.DISABLED.value
126
+ else UIStyle.RED.value
127
+ )
128
+ table.add_row(item["name"], f"[{style}]{status}[/{style}]", item["detail"])
129
+ return table
130
+
131
+ @staticmethod
132
+ def workspace_overview(items: List[dict]) -> Table:
133
+ table = Table(
134
+ Column(header="Workspace", width=24),
135
+ Column(header="Status", width=12),
136
+ Column(header="Repos", width=8, justify="right"),
137
+ Column(header="Detail", overflow="ellipsis"),
138
+ expand=True,
139
+ header_style="bold",
140
+ )
141
+ for item in items:
142
+ status = item["status"]
143
+ style = (
144
+ UIStyle.GREEN.value
145
+ if status == WorkspaceSyncStatus.SYNCED.value
146
+ else UIStyle.RED.value
147
+ if status == WorkspaceSyncStatus.ERROR.value
148
+ else UIStyle.YELLOW.value
149
+ )
150
+ table.add_row(
151
+ item["name"],
152
+ f"[{style}]{status}[/{style}]",
153
+ str(len(item.get("repos", []))),
154
+ item["detail"],
155
+ )
156
+ return table
157
+
158
+ @staticmethod
159
+ def workspace_repos_group(items: List[dict]):
160
+ blocks = []
161
+ for item in items:
162
+ repos = item.get("repos", [])
163
+ heading = Text(f"{item['name']}", style="bold")
164
+ if not repos:
165
+ blocks.append(Group(heading, Text(" (no git repos found)", style=UIStyle.DIM.value)))
166
+ continue
167
+
168
+ repo_table = Table(
169
+ Column(header="Repo", width=28),
170
+ Column(header="Status", width=12),
171
+ Column(header="Detail", overflow="ellipsis"),
172
+ expand=True,
173
+ header_style="bold",
174
+ )
175
+ for repo in repos:
176
+ status = repo["status"]
177
+ style = UIStyle.GREEN.value if status == RepoSyncStatus.SYNCED.value else UIStyle.YELLOW.value
178
+ label = RepoSyncStatus.SYNCED.value if status == RepoSyncStatus.SYNCED.value else "needs sync"
179
+ repo_table.add_row(repo["repo"], f"[{style}]{label}[/{style}]", repo["detail"])
180
+ blocks.append(Group(heading, Padding(repo_table, (0, 0, 0, 2))))
181
+
182
+ if not blocks:
183
+ return Text("No workspace details.", style=UIStyle.DIM.value)
184
+ return Group(*blocks)
185
+
186
+
187
+ class AppsTable:
188
+ @staticmethod
189
+ def apps_table(items: List[dict]) -> Table:
190
+ table = Table(
191
+ Column(header="App", width=14),
192
+ Column(header="Status", width=12),
193
+ Column(header="Detail", overflow="ellipsis"),
194
+ expand=True,
195
+ header_style="bold",
196
+ )
197
+ for item in items:
198
+ status = item["status"]
199
+ style = UIStyle.GREEN.value if status == AppSyncStatus.ENABLED.value else UIStyle.YELLOW.value
200
+ table.add_row(item["name"], f"[{style}]{status}[/{style}]", item["detail"])
201
+ return table
code_agnostic/utils.py ADDED
@@ -0,0 +1,53 @@
1
+ import json
2
+ import shutil
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+ from typing import Any, Optional, Tuple
6
+
7
+
8
+ def now_stamp() -> str:
9
+ return datetime.now().strftime("%Y%m%d-%H%M%S")
10
+
11
+
12
+ def read_json(path: Path) -> Any:
13
+ with path.open("r", encoding="utf-8") as handle:
14
+ return json.load(handle)
15
+
16
+
17
+ def read_json_safe(path: Path) -> Tuple[Optional[Any], Optional[str]]:
18
+ if not path.exists():
19
+ return None, None
20
+ if path.stat().st_size == 0:
21
+ return None, None
22
+ try:
23
+ return read_json(path), None
24
+ except Exception as exc:
25
+ return None, str(exc)
26
+
27
+
28
+ def write_json(path: Path, payload: Any) -> None:
29
+ path.parent.mkdir(parents=True, exist_ok=True)
30
+ with path.open("w", encoding="utf-8") as handle:
31
+ json.dump(payload, handle, indent=2, sort_keys=False)
32
+ handle.write("\n")
33
+
34
+
35
+ def backup_file(path: Path) -> Path:
36
+ backup_path = Path(f"{path}.bak-{now_stamp()}")
37
+ shutil.copy2(path, backup_path)
38
+ return backup_path
39
+
40
+
41
+ def same_json(path: Path, payload: Any) -> bool:
42
+ existing, error = read_json_safe(path)
43
+ if error is not None:
44
+ return False
45
+ return existing == payload
46
+
47
+
48
+ def is_under(path: Path, root: Path) -> bool:
49
+ try:
50
+ path.resolve().relative_to(root.resolve())
51
+ return True
52
+ except Exception:
53
+ return False
@@ -0,0 +1,45 @@
1
+ import os
2
+ from pathlib import Path
3
+ from typing import Optional
4
+
5
+ from code_agnostic.constants import AGENTS_FILENAME, GIT_DIRNAME, WORKSPACE_IGNORED_DIRS, WORKSPACE_RULE_FILES
6
+
7
+
8
+ class WorkspaceService:
9
+ def resolve_rules_file(self, workspace_path: Path) -> Optional[Path]:
10
+ for candidate in WORKSPACE_RULE_FILES:
11
+ rule_path = workspace_path / candidate
12
+ if rule_path.exists() and rule_path.is_file():
13
+ return rule_path
14
+ return None
15
+
16
+
17
+ def discover_git_repos(self, workspace_path: Path) -> list[Path]:
18
+ repos: list[Path] = []
19
+ workspace_real = workspace_path.resolve()
20
+
21
+ for root, dir_names, _ in os.walk(str(workspace_real), topdown=True):
22
+ current = Path(root)
23
+ if current == workspace_real:
24
+ pass
25
+ elif (current / GIT_DIRNAME).exists():
26
+ repos.append(current)
27
+ dir_names[:] = []
28
+ continue
29
+
30
+ dir_names[:] = [name for name in dir_names if not name.startswith(".") and name not in WORKSPACE_IGNORED_DIRS]
31
+
32
+ return sorted(set(repos))
33
+
34
+ def workspace_sync_targets(self, workspace_path: Path, rules_file: Optional[Path]) -> list[Path]:
35
+ if rules_file is None:
36
+ return []
37
+ return [repo / AGENTS_FILENAME for repo in self.discover_git_repos(workspace_path)]
38
+
39
+
40
+ def resolve_workspace_rules_file(workspace_path: Path) -> Optional[Path]:
41
+ return WorkspaceService().resolve_rules_file(workspace_path)
42
+
43
+
44
+ def list_workspace_repos(workspace_path: Path) -> list[Path]:
45
+ return WorkspaceService().discover_git_repos(workspace_path)