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,275 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Dict
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
|
|
7
|
+
from code_agnostic.apps import AppsService
|
|
8
|
+
from code_agnostic.executor import SyncExecutor
|
|
9
|
+
from code_agnostic.models import AppId, EditorStatusRow, EditorSyncStatus, SyncPlan, SyncTarget
|
|
10
|
+
from code_agnostic.planner import SyncPlanner
|
|
11
|
+
from code_agnostic.repositories.common import CommonRepository
|
|
12
|
+
from code_agnostic.repositories.opencode import OpenCodeRepository
|
|
13
|
+
from code_agnostic.status import StatusService
|
|
14
|
+
from code_agnostic.tui import SyncConsoleUI
|
|
15
|
+
from code_agnostic.workspaces import WorkspaceService
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _repos_from_obj(_obj: Dict[str, str]) -> tuple[CommonRepository, OpenCodeRepository]:
|
|
19
|
+
common = CommonRepository()
|
|
20
|
+
opencode = OpenCodeRepository()
|
|
21
|
+
return common, opencode
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _empty_plan(skipped_reason: str) -> SyncPlan:
|
|
25
|
+
return SyncPlan(actions=[], errors=[], skipped=[skipped_reason])
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _cursor_status_row(apps: AppsService) -> EditorStatusRow:
|
|
29
|
+
if apps.is_enabled(AppId.CURSOR):
|
|
30
|
+
return EditorStatusRow(
|
|
31
|
+
name="cursor",
|
|
32
|
+
status=EditorSyncStatus.ERROR,
|
|
33
|
+
detail="enabled but sync is not implemented yet",
|
|
34
|
+
)
|
|
35
|
+
return EditorStatusRow(
|
|
36
|
+
name="cursor",
|
|
37
|
+
status=EditorSyncStatus.DISABLED,
|
|
38
|
+
detail="disabled by apps config",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
|
|
43
|
+
@click.pass_context
|
|
44
|
+
def cli(ctx: click.Context) -> None:
|
|
45
|
+
"""OpenCode-first config sync."""
|
|
46
|
+
ctx.obj = {}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@cli.command(help="Build and print a dry-run plan.")
|
|
50
|
+
@click.pass_obj
|
|
51
|
+
def plan(obj: Dict[str, str]) -> None:
|
|
52
|
+
ui = SyncConsoleUI(Console())
|
|
53
|
+
common, opencode = _repos_from_obj(obj)
|
|
54
|
+
apps = AppsService(common)
|
|
55
|
+
|
|
56
|
+
if not apps.is_enabled(AppId.OPENCODE):
|
|
57
|
+
enabled = apps.enabled_apps()
|
|
58
|
+
if enabled:
|
|
59
|
+
names = ", ".join([app.value for app in enabled])
|
|
60
|
+
plan_result = _empty_plan(f"Enabled apps not implemented for planning yet: {names}")
|
|
61
|
+
else:
|
|
62
|
+
plan_result = _empty_plan("No apps enabled for plan (enable 'opencode' first).")
|
|
63
|
+
else:
|
|
64
|
+
try:
|
|
65
|
+
plan_result = SyncPlanner(common=common, opencode=opencode).build()
|
|
66
|
+
except Exception as exc:
|
|
67
|
+
raise click.ClickException(f"Fatal: {exc}")
|
|
68
|
+
|
|
69
|
+
ui.render_plan(plan_result, mode="plan")
|
|
70
|
+
|
|
71
|
+
if plan_result.errors:
|
|
72
|
+
raise click.exceptions.Exit(1)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@cli.command(help="Apply planned sync changes.")
|
|
76
|
+
@click.argument(
|
|
77
|
+
"target",
|
|
78
|
+
required=False,
|
|
79
|
+
type=click.Choice([SyncTarget.ALL.value, SyncTarget.OPENCODE.value], case_sensitive=False),
|
|
80
|
+
default=SyncTarget.ALL.value,
|
|
81
|
+
)
|
|
82
|
+
@click.pass_obj
|
|
83
|
+
def apply(obj: Dict[str, str], target: str) -> None:
|
|
84
|
+
ui = SyncConsoleUI(Console())
|
|
85
|
+
common, opencode = _repos_from_obj(obj)
|
|
86
|
+
apps = AppsService(common)
|
|
87
|
+
|
|
88
|
+
if not apps.is_enabled(AppId.OPENCODE):
|
|
89
|
+
enabled = apps.enabled_apps()
|
|
90
|
+
if enabled:
|
|
91
|
+
names = ", ".join([app.value for app in enabled])
|
|
92
|
+
plan_result = _empty_plan(f"Enabled apps not implemented for apply yet: {names}")
|
|
93
|
+
else:
|
|
94
|
+
plan_result = _empty_plan("No apps enabled for apply (enable 'opencode' first).")
|
|
95
|
+
ui.render_plan(plan_result, mode=f"apply:{target.lower()}")
|
|
96
|
+
ui.render_apply_result(applied=0, failed=0, failures=[])
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
plan_result = SyncPlanner(common=common, opencode=opencode).build()
|
|
101
|
+
except Exception as exc:
|
|
102
|
+
raise click.ClickException(f"Fatal: {exc}")
|
|
103
|
+
|
|
104
|
+
normalized_target = SyncTarget(target.lower())
|
|
105
|
+
scoped_plan = plan_result.filter_for_target(
|
|
106
|
+
target=normalized_target,
|
|
107
|
+
config_path=opencode.config_path,
|
|
108
|
+
skills_root=opencode.skills_dir,
|
|
109
|
+
agents_root=opencode.agents_dir,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
ui.render_plan(scoped_plan, mode=f"apply:{normalized_target.value}")
|
|
113
|
+
|
|
114
|
+
if scoped_plan.errors:
|
|
115
|
+
raise click.ClickException("Apply aborted due to planning/parsing errors above.")
|
|
116
|
+
|
|
117
|
+
applied, failed, failures = SyncExecutor(common=common, opencode=opencode).execute(scoped_plan)
|
|
118
|
+
ui.render_apply_result(applied, failed, failures)
|
|
119
|
+
|
|
120
|
+
if failed:
|
|
121
|
+
raise click.exceptions.Exit(1)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@cli.command(help="Show sync status for editors and workspaces.")
|
|
125
|
+
@click.pass_obj
|
|
126
|
+
def status(obj: Dict[str, str]) -> None:
|
|
127
|
+
ui = SyncConsoleUI(Console())
|
|
128
|
+
common, opencode = _repos_from_obj(obj)
|
|
129
|
+
apps = AppsService(common)
|
|
130
|
+
status_service = StatusService()
|
|
131
|
+
|
|
132
|
+
if apps.is_enabled(AppId.OPENCODE):
|
|
133
|
+
try:
|
|
134
|
+
plan_result = SyncPlanner(common=common, opencode=opencode).build()
|
|
135
|
+
editor_rows = status_service.build_editor_status(plan_result, opencode)
|
|
136
|
+
editor_rows = [row for row in editor_rows if row.name != "cursor"]
|
|
137
|
+
editor_rows.append(_cursor_status_row(apps))
|
|
138
|
+
except Exception as exc:
|
|
139
|
+
editor_rows = [
|
|
140
|
+
EditorStatusRow(
|
|
141
|
+
name="opencode",
|
|
142
|
+
status=EditorSyncStatus.ERROR,
|
|
143
|
+
detail=f"cannot evaluate ({exc})",
|
|
144
|
+
),
|
|
145
|
+
_cursor_status_row(apps),
|
|
146
|
+
]
|
|
147
|
+
workspace_rows = status_service.build_workspace_status(common)
|
|
148
|
+
else:
|
|
149
|
+
editor_rows = [
|
|
150
|
+
EditorStatusRow(
|
|
151
|
+
name="opencode",
|
|
152
|
+
status=EditorSyncStatus.DISABLED,
|
|
153
|
+
detail="disabled by apps config",
|
|
154
|
+
),
|
|
155
|
+
_cursor_status_row(apps),
|
|
156
|
+
]
|
|
157
|
+
workspace_rows = []
|
|
158
|
+
|
|
159
|
+
ui.render_status(
|
|
160
|
+
[item.as_dict() for item in editor_rows],
|
|
161
|
+
[item.as_dict() for item in workspace_rows],
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@cli.group(help="Enable or disable app sync targets.")
|
|
166
|
+
def apps() -> None:
|
|
167
|
+
pass
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@apps.command("list", help="List app sync target status.")
|
|
171
|
+
@click.pass_obj
|
|
172
|
+
def apps_list(obj: Dict[str, str]) -> None:
|
|
173
|
+
ui = SyncConsoleUI(Console())
|
|
174
|
+
common, _ = _repos_from_obj(obj)
|
|
175
|
+
service = AppsService(common)
|
|
176
|
+
ui.render_apps([row.as_dict() for row in service.list_status_rows()])
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@apps.command("enable", help="Enable app sync target.")
|
|
180
|
+
@click.argument("name", type=click.Choice([app.value for app in AppId], case_sensitive=False))
|
|
181
|
+
@click.pass_obj
|
|
182
|
+
def apps_enable(obj: Dict[str, str], name: str) -> None:
|
|
183
|
+
ui = SyncConsoleUI(Console())
|
|
184
|
+
common, _ = _repos_from_obj(obj)
|
|
185
|
+
service = AppsService(common)
|
|
186
|
+
app_id = AppId(name.lower())
|
|
187
|
+
service.enable(app_id)
|
|
188
|
+
ui.render_apps([row.as_dict() for row in service.list_status_rows()])
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@apps.command("disable", help="Disable app sync target.")
|
|
192
|
+
@click.argument("name", type=click.Choice([app.value for app in AppId], case_sensitive=False))
|
|
193
|
+
@click.pass_obj
|
|
194
|
+
def apps_disable(obj: Dict[str, str], name: str) -> None:
|
|
195
|
+
ui = SyncConsoleUI(Console())
|
|
196
|
+
common, _ = _repos_from_obj(obj)
|
|
197
|
+
service = AppsService(common)
|
|
198
|
+
app_id = AppId(name.lower())
|
|
199
|
+
service.disable(app_id)
|
|
200
|
+
ui.render_apps([row.as_dict() for row in service.list_status_rows()])
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@cli.group(help="Manage workspace roots for repo rule propagation.")
|
|
204
|
+
def workspaces() -> None:
|
|
205
|
+
pass
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@workspaces.command("add", help="Add a workspace by name and path.")
|
|
209
|
+
@click.argument("name")
|
|
210
|
+
@click.argument("path", type=click.Path(path_type=Path))
|
|
211
|
+
@click.pass_obj
|
|
212
|
+
def workspaces_add(obj: Dict[str, str], name: str, path: Path) -> None:
|
|
213
|
+
ui = SyncConsoleUI(Console())
|
|
214
|
+
common, _ = _repos_from_obj(obj)
|
|
215
|
+
try:
|
|
216
|
+
common.add_workspace(name, path)
|
|
217
|
+
except ValueError as exc:
|
|
218
|
+
raise click.ClickException(str(exc))
|
|
219
|
+
ui.render_workspace_saved(name, str(path.expanduser().resolve()))
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@workspaces.command("remove", help="Remove a workspace from config by name.")
|
|
223
|
+
@click.argument("name")
|
|
224
|
+
@click.pass_obj
|
|
225
|
+
def workspaces_remove(obj: Dict[str, str], name: str) -> None:
|
|
226
|
+
ui = SyncConsoleUI(Console())
|
|
227
|
+
common, _ = _repos_from_obj(obj)
|
|
228
|
+
existing = {item["name"]: item["path"] for item in common.load_workspaces()}
|
|
229
|
+
removed = common.remove_workspace(name)
|
|
230
|
+
if not removed:
|
|
231
|
+
raise click.ClickException(f"Workspace not found: {name}")
|
|
232
|
+
ui.render_workspace_saved(name, existing.get(name, ""), removed=True)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@workspaces.command("list", help="List configured workspaces and detected repos.")
|
|
236
|
+
@click.pass_obj
|
|
237
|
+
def workspaces_list(obj: Dict[str, str]) -> None:
|
|
238
|
+
ui = SyncConsoleUI(Console())
|
|
239
|
+
common, _ = _repos_from_obj(obj)
|
|
240
|
+
workspace_service = WorkspaceService()
|
|
241
|
+
|
|
242
|
+
overview: list[dict] = []
|
|
243
|
+
for item in common.load_workspaces():
|
|
244
|
+
workspace_path = Path(item["path"])
|
|
245
|
+
repos: list[str] = []
|
|
246
|
+
if workspace_path.exists() and workspace_path.is_dir():
|
|
247
|
+
repos = [
|
|
248
|
+
str(path.relative_to(workspace_path))
|
|
249
|
+
for path in workspace_service.discover_git_repos(workspace_path)
|
|
250
|
+
]
|
|
251
|
+
overview.append(
|
|
252
|
+
{
|
|
253
|
+
"name": item["name"],
|
|
254
|
+
"path": item["path"],
|
|
255
|
+
"repos": repos,
|
|
256
|
+
}
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
ui.render_workspaces_overview(overview)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def main() -> int:
|
|
263
|
+
try:
|
|
264
|
+
cli(standalone_mode=False)
|
|
265
|
+
except click.exceptions.Exit as exc:
|
|
266
|
+
code = exc.exit_code
|
|
267
|
+
return code if isinstance(code, int) else 1
|
|
268
|
+
except click.ClickException as exc:
|
|
269
|
+
exc.show()
|
|
270
|
+
return 2
|
|
271
|
+
return 0
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
if __name__ == "__main__":
|
|
275
|
+
raise SystemExit(main())
|
code_agnostic/apps.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from code_agnostic.models import AppId, AppStatusRow, AppSyncStatus
|
|
4
|
+
from code_agnostic.repositories.common import CommonRepository
|
|
5
|
+
from code_agnostic.utils import read_json_safe, write_json
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AppsService:
|
|
9
|
+
def __init__(self, common: CommonRepository) -> None:
|
|
10
|
+
self.common = common
|
|
11
|
+
|
|
12
|
+
@property
|
|
13
|
+
def apps_path(self) -> Path:
|
|
14
|
+
return self.common.config_dir / "apps.json"
|
|
15
|
+
|
|
16
|
+
def load_apps(self) -> dict[str, bool]:
|
|
17
|
+
payload, error = read_json_safe(self.apps_path)
|
|
18
|
+
if error is not None or not isinstance(payload, dict):
|
|
19
|
+
return self._default_apps()
|
|
20
|
+
|
|
21
|
+
result = self._default_apps()
|
|
22
|
+
for app in AppId:
|
|
23
|
+
value = payload.get(app.value)
|
|
24
|
+
if isinstance(value, bool):
|
|
25
|
+
result[app.value] = value
|
|
26
|
+
return result
|
|
27
|
+
|
|
28
|
+
def save_apps(self, apps: dict[str, bool]) -> None:
|
|
29
|
+
normalized = self._default_apps()
|
|
30
|
+
for app in AppId:
|
|
31
|
+
value = apps.get(app.value)
|
|
32
|
+
if isinstance(value, bool):
|
|
33
|
+
normalized[app.value] = value
|
|
34
|
+
write_json(self.apps_path, normalized)
|
|
35
|
+
|
|
36
|
+
def is_enabled(self, app: AppId) -> bool:
|
|
37
|
+
return self.load_apps().get(app.value, False)
|
|
38
|
+
|
|
39
|
+
def set_enabled(self, app: AppId, enabled: bool) -> None:
|
|
40
|
+
apps = self.load_apps()
|
|
41
|
+
apps[app.value] = enabled
|
|
42
|
+
self.save_apps(apps)
|
|
43
|
+
|
|
44
|
+
def enable(self, app: AppId) -> None:
|
|
45
|
+
self.set_enabled(app=app, enabled=True)
|
|
46
|
+
|
|
47
|
+
def disable(self, app: AppId) -> None:
|
|
48
|
+
self.set_enabled(app=app, enabled=False)
|
|
49
|
+
|
|
50
|
+
def list_status_rows(self) -> list[AppStatusRow]:
|
|
51
|
+
apps = self.load_apps()
|
|
52
|
+
rows: list[AppStatusRow] = []
|
|
53
|
+
for app in AppId:
|
|
54
|
+
enabled = apps.get(app.value, False)
|
|
55
|
+
detail = "enabled by user" if enabled else "disabled by default"
|
|
56
|
+
rows.append(
|
|
57
|
+
AppStatusRow(
|
|
58
|
+
name=app,
|
|
59
|
+
status=AppSyncStatus.ENABLED if enabled else AppSyncStatus.DISABLED,
|
|
60
|
+
detail=detail,
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
return rows
|
|
64
|
+
|
|
65
|
+
def enabled_apps(self) -> list[AppId]:
|
|
66
|
+
apps = self.load_apps()
|
|
67
|
+
return [app for app in AppId if apps.get(app.value, False)]
|
|
68
|
+
|
|
69
|
+
@staticmethod
|
|
70
|
+
def _default_apps() -> dict[str, bool]:
|
|
71
|
+
return {app.value: False for app in AppId}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from typing import Final, Tuple
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
AGENTS_FILENAME: Final[str] = "AGENTS.md"
|
|
5
|
+
CLAUDE_FILENAME: Final[str] = "CLAUDE.md"
|
|
6
|
+
GIT_DIRNAME: Final[str] = ".git"
|
|
7
|
+
|
|
8
|
+
WORKSPACE_RULE_FILES: Final[Tuple[str, ...]] = (
|
|
9
|
+
AGENTS_FILENAME,
|
|
10
|
+
AGENTS_FILENAME.lower(),
|
|
11
|
+
CLAUDE_FILENAME,
|
|
12
|
+
CLAUDE_FILENAME.lower(),
|
|
13
|
+
)
|
|
14
|
+
WORKSPACE_RULE_FILES_DISPLAY: Final[str] = "AGENTS.md/CLAUDE.md"
|
|
15
|
+
|
|
16
|
+
WORKSPACE_IGNORED_DIRS: Final[Tuple[str, ...]] = (
|
|
17
|
+
"node_modules",
|
|
18
|
+
".venv",
|
|
19
|
+
)
|
code_agnostic/errors.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SyncAppError(Exception):
|
|
5
|
+
"""Base user-facing application error."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SyncFileError(SyncAppError):
|
|
9
|
+
def __init__(self, path: Path, message: str) -> None:
|
|
10
|
+
self.path = path
|
|
11
|
+
self.message = message
|
|
12
|
+
super().__init__(f"{message}: {path}")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MissingConfigFileError(SyncFileError):
|
|
16
|
+
def __init__(self, path: Path) -> None:
|
|
17
|
+
super().__init__(path=path, message="Missing required config file")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class InvalidJsonFormatError(SyncFileError):
|
|
21
|
+
def __init__(self, path: Path, detail: str) -> None:
|
|
22
|
+
self.detail = detail
|
|
23
|
+
super().__init__(path=path, message=f"Invalid JSON format ({detail})")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class InvalidConfigSchemaError(SyncFileError):
|
|
27
|
+
def __init__(self, path: Path, detail: str) -> None:
|
|
28
|
+
self.detail = detail
|
|
29
|
+
super().__init__(path=path, message=f"Invalid config schema ({detail})")
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional, Protocol
|
|
6
|
+
|
|
7
|
+
from code_agnostic.constants import AGENTS_FILENAME
|
|
8
|
+
from code_agnostic.models import Action, ActionKind, ActionStatus, SyncPlan
|
|
9
|
+
from code_agnostic.repositories.base import ISourceRepository, ITargetRepository
|
|
10
|
+
from code_agnostic.utils import backup_file, write_json
|
|
11
|
+
from code_agnostic.workspaces import WorkspaceService
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class ExecutionContext:
|
|
16
|
+
common: ISourceRepository
|
|
17
|
+
opencode: ITargetRepository
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ActionHandler(Protocol):
|
|
21
|
+
def handle(self, action: Action, context: ExecutionContext) -> tuple[bool, Optional[str]]:
|
|
22
|
+
...
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class WriteJsonHandler:
|
|
26
|
+
def handle(self, action: Action, context: ExecutionContext) -> tuple[bool, Optional[str]]:
|
|
27
|
+
if action.status == ActionStatus.NOOP:
|
|
28
|
+
return False, None
|
|
29
|
+
if action.path.exists():
|
|
30
|
+
backup_file(action.path)
|
|
31
|
+
write_json(action.path, action.payload)
|
|
32
|
+
return True, None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SymlinkHandler:
|
|
36
|
+
def handle(self, action: Action, context: ExecutionContext) -> tuple[bool, Optional[str]]:
|
|
37
|
+
if action.status == ActionStatus.NOOP:
|
|
38
|
+
return False, None
|
|
39
|
+
if action.status == ActionStatus.CONFLICT:
|
|
40
|
+
return False, f"Conflict (not overwritten): {action.path}"
|
|
41
|
+
if action.source is None:
|
|
42
|
+
return False, f"Missing source for symlink action: {action.path}"
|
|
43
|
+
|
|
44
|
+
action.path.parent.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
if action.path.exists() or action.path.is_symlink():
|
|
46
|
+
action.path.unlink()
|
|
47
|
+
action.path.symlink_to(action.source.resolve())
|
|
48
|
+
return True, None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class RemoveSymlinkHandler:
|
|
52
|
+
def handle(self, action: Action, context: ExecutionContext) -> tuple[bool, Optional[str]]:
|
|
53
|
+
if action.status == ActionStatus.NOOP:
|
|
54
|
+
return False, None
|
|
55
|
+
if action.status == ActionStatus.CONFLICT:
|
|
56
|
+
return False, f"Stale cleanup conflict (not symlink): {action.path}"
|
|
57
|
+
if action.path.is_symlink():
|
|
58
|
+
action.path.unlink()
|
|
59
|
+
return True, None
|
|
60
|
+
return False, None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class SyncExecutor:
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
common: ISourceRepository,
|
|
67
|
+
opencode: ITargetRepository,
|
|
68
|
+
workspace_service: Optional[WorkspaceService] = None,
|
|
69
|
+
) -> None:
|
|
70
|
+
self.context = ExecutionContext(common=common, opencode=opencode)
|
|
71
|
+
self.workspace_service = workspace_service or WorkspaceService()
|
|
72
|
+
self.handlers: dict[ActionKind, ActionHandler] = {
|
|
73
|
+
ActionKind.WRITE_JSON: WriteJsonHandler(),
|
|
74
|
+
ActionKind.SYMLINK: SymlinkHandler(),
|
|
75
|
+
ActionKind.REMOVE_SYMLINK: RemoveSymlinkHandler(),
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
def execute(self, plan: SyncPlan) -> tuple[int, int, list[str]]:
|
|
79
|
+
applied = 0
|
|
80
|
+
failed = 0
|
|
81
|
+
failures: list[str] = []
|
|
82
|
+
|
|
83
|
+
for action in plan.actions:
|
|
84
|
+
try:
|
|
85
|
+
handler = self.handlers.get(action.kind)
|
|
86
|
+
if handler is None:
|
|
87
|
+
failed += 1
|
|
88
|
+
failures.append(f"Unknown action kind: {action.kind.value}")
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
changed, failure = handler.handle(action, self.context)
|
|
92
|
+
if failure is not None:
|
|
93
|
+
failed += 1
|
|
94
|
+
failures.append(failure)
|
|
95
|
+
continue
|
|
96
|
+
if changed:
|
|
97
|
+
applied += 1
|
|
98
|
+
except Exception as exc:
|
|
99
|
+
failed += 1
|
|
100
|
+
failures.append(f"{action.kind.value} failed for {action.path}: {exc}")
|
|
101
|
+
|
|
102
|
+
self._persist_state(plan=plan)
|
|
103
|
+
return applied, failed, failures
|
|
104
|
+
|
|
105
|
+
def _persist_state(self, plan: SyncPlan) -> None:
|
|
106
|
+
common = self.context.common
|
|
107
|
+
opencode = self.context.opencode
|
|
108
|
+
|
|
109
|
+
managed_skill_links = self._collect_managed_links(opencode.skills_dir, common.skills_dir)
|
|
110
|
+
managed_agent_links = self._collect_managed_links(opencode.agents_dir, common.agents_dir)
|
|
111
|
+
managed_workspace_links = self._collect_workspace_links(common)
|
|
112
|
+
|
|
113
|
+
updated_at = datetime.now().isoformat(timespec="seconds")
|
|
114
|
+
state = {
|
|
115
|
+
"updated_at": updated_at,
|
|
116
|
+
"managed_skill_links": sorted(set(managed_skill_links)),
|
|
117
|
+
"managed_agent_links": sorted(set(managed_agent_links)),
|
|
118
|
+
"managed_workspace_links": sorted(set(managed_workspace_links)),
|
|
119
|
+
"skipped": plan.skipped,
|
|
120
|
+
}
|
|
121
|
+
common.save_state(state)
|
|
122
|
+
|
|
123
|
+
def _collect_workspace_links(self, common: ISourceRepository) -> list[str]:
|
|
124
|
+
managed: list[str] = []
|
|
125
|
+
for workspace in common.load_workspaces():
|
|
126
|
+
workspace_path = Path(workspace["path"])
|
|
127
|
+
if not workspace_path.exists() or not workspace_path.is_dir():
|
|
128
|
+
continue
|
|
129
|
+
rules_file = self.workspace_service.resolve_rules_file(workspace_path)
|
|
130
|
+
if rules_file is None:
|
|
131
|
+
continue
|
|
132
|
+
rules_target = str(rules_file.resolve())
|
|
133
|
+
for repo in self.workspace_service.discover_git_repos(workspace_path):
|
|
134
|
+
target = repo / AGENTS_FILENAME
|
|
135
|
+
if not target.is_symlink():
|
|
136
|
+
continue
|
|
137
|
+
if os.path.realpath(target) == rules_target:
|
|
138
|
+
managed.append(str(target))
|
|
139
|
+
return managed
|
|
140
|
+
|
|
141
|
+
@staticmethod
|
|
142
|
+
def _collect_managed_links(target_root: Path, source_root: Path) -> list[str]:
|
|
143
|
+
if not target_root.exists():
|
|
144
|
+
return []
|
|
145
|
+
|
|
146
|
+
managed: list[str] = []
|
|
147
|
+
source_prefix = str(source_root.resolve())
|
|
148
|
+
for child in target_root.iterdir():
|
|
149
|
+
if not child.is_symlink():
|
|
150
|
+
continue
|
|
151
|
+
target = os.path.realpath(child)
|
|
152
|
+
if target.startswith(source_prefix):
|
|
153
|
+
managed.append(str(child))
|
|
154
|
+
return managed
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def execute_apply(plan: SyncPlan, common: ISourceRepository, opencode: ITargetRepository) -> tuple[int, int, list[str]]:
|
|
158
|
+
return SyncExecutor(common=common, opencode=opencode).execute(plan)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from abc import ABC
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class IConfigMapper(ABC):
|
|
7
|
+
def map_mcp_servers(self, mcp_servers: dict[str, Any]) -> dict[str, Any]:
|
|
8
|
+
return dict(mcp_servers)
|
|
9
|
+
|
|
10
|
+
def map_skill_source(self, source: Path) -> Path:
|
|
11
|
+
return source
|
|
12
|
+
|
|
13
|
+
def map_agent_source(self, source: Path) -> Path:
|
|
14
|
+
return source
|
|
15
|
+
|
|
16
|
+
def map_workspace_rules_source(self, source: Path) -> Path:
|
|
17
|
+
return source
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from copy import deepcopy
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from code_agnostic.mappers.base import IConfigMapper
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _as_command_array(command: Any, args: Any) -> list[str]:
|
|
8
|
+
if isinstance(command, list):
|
|
9
|
+
base = [str(item) for item in command]
|
|
10
|
+
elif isinstance(command, str):
|
|
11
|
+
base = [command]
|
|
12
|
+
else:
|
|
13
|
+
base = []
|
|
14
|
+
|
|
15
|
+
if isinstance(args, list):
|
|
16
|
+
base.extend(str(item) for item in args)
|
|
17
|
+
return base
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class OpenCodeMapper(IConfigMapper):
|
|
21
|
+
def map_mcp_servers(self, mcp_servers: dict[str, Any]) -> dict[str, Any]:
|
|
22
|
+
mapped: dict[str, Any] = {}
|
|
23
|
+
for name, server in mcp_servers.items():
|
|
24
|
+
if not isinstance(server, dict):
|
|
25
|
+
continue
|
|
26
|
+
|
|
27
|
+
out: dict[str, Any] = {}
|
|
28
|
+
|
|
29
|
+
if "url" in server:
|
|
30
|
+
out["type"] = "remote"
|
|
31
|
+
out["url"] = server["url"]
|
|
32
|
+
elif "command" in server:
|
|
33
|
+
command_list = _as_command_array(server.get("command"), server.get("args"))
|
|
34
|
+
if not command_list:
|
|
35
|
+
continue
|
|
36
|
+
out["type"] = "local"
|
|
37
|
+
out["command"] = command_list
|
|
38
|
+
else:
|
|
39
|
+
continue
|
|
40
|
+
|
|
41
|
+
for passthrough_key in ["headers", "environment", "enabled", "oauth", "timeout"]:
|
|
42
|
+
if passthrough_key in server:
|
|
43
|
+
out[passthrough_key] = deepcopy(server[passthrough_key])
|
|
44
|
+
|
|
45
|
+
mapped[name] = out
|
|
46
|
+
return mapped
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def map_mcp_servers_to_opencode(mcp_servers: dict[str, Any]) -> dict[str, Any]:
|
|
50
|
+
return OpenCodeMapper().map_mcp_servers(mcp_servers)
|