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,3 @@
1
+ __all__ = ["__version__"]
2
+
3
+ __version__ = "0.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
+ )
@@ -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,4 @@
1
+ from code_agnostic.mappers.base import IConfigMapper
2
+ from code_agnostic.mappers.opencode import OpenCodeMapper, map_mcp_servers_to_opencode
3
+
4
+ __all__ = ["IConfigMapper", "OpenCodeMapper", "map_mcp_servers_to_opencode"]
@@ -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)