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
|
@@ -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
|
code_agnostic/status.py
ADDED
|
@@ -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,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)
|