network-aiops 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.
mcp_server/__init__.py ADDED
File without changes
mcp_server/_shared.py ADDED
@@ -0,0 +1,113 @@
1
+ """Shared MCP server primitives: the FastMCP instance, manager helper,
2
+ error sanitisation, and the ``@tool_errors`` decorator.
3
+
4
+ Tool modules under ``mcp_server/tools/`` import ``mcp`` from here and register
5
+ their ``@mcp.tool()`` functions onto it. ``mcp_server/server.py`` then imports
6
+ those modules and runs the server.
7
+
8
+ Keep ``Optional[X]`` (never PEP 604 ``X | None``) in any FastMCP-reflected
9
+ tool signature — on older mcp/pydantic the union eval'd to ``types.UnionType``
10
+ crashes FastMCP's ``issubclass`` check.
11
+ """
12
+
13
+ import functools
14
+ import logging
15
+ import os
16
+ from collections.abc import Callable
17
+ from pathlib import Path
18
+ from typing import Any, Optional
19
+
20
+ from mcp.server.fastmcp import FastMCP
21
+
22
+ from network_aiops.config import load_config
23
+ from network_aiops.connection import ConnectionManager, NetworkApiError
24
+ from network_aiops.governance import sanitize
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ _DOCTOR_HINT = "Run 'network-aiops doctor' to verify device config and reachability."
29
+
30
+ _SUPPORTED = "ios, nxos, nxos_ssh, iosxr, eos, junos"
31
+
32
+
33
+ def _safe_error(exc: Exception, tool: str) -> str:
34
+ """Return an agent-safe error string; log full detail server-side only."""
35
+ logger.error("Tool %s failed", tool, exc_info=True)
36
+ _passthrough = (
37
+ ValueError,
38
+ FileNotFoundError,
39
+ KeyError,
40
+ PermissionError,
41
+ TimeoutError,
42
+ ConnectionError,
43
+ NetworkApiError,
44
+ )
45
+ if isinstance(exc, _passthrough):
46
+ return sanitize(str(exc), 300)
47
+ return f"{type(exc).__name__}: operation failed."
48
+
49
+
50
+ def tool_errors(shape: str = "dict") -> Callable:
51
+ """Wrap a tool body in the canonical try/except → ``_safe_error`` pattern.
52
+
53
+ Place this *between* ``@governed_tool`` and the function so the audit
54
+ decorator and FastMCP still see the original signature.
55
+ """
56
+
57
+ def decorator(func: Callable) -> Callable:
58
+ name = func.__name__
59
+
60
+ @functools.wraps(func)
61
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
62
+ try:
63
+ return func(*args, **kwargs)
64
+ except Exception as e: # noqa: BLE001 — sanitised below
65
+ msg = _safe_error(e, name)
66
+ if shape == "list":
67
+ return [{"error": msg, "hint": _DOCTOR_HINT}]
68
+ if shape == "str":
69
+ return f"Error: {msg} {_DOCTOR_HINT}"
70
+ return {"error": msg, "hint": _DOCTOR_HINT}
71
+
72
+ return wrapper
73
+
74
+ return decorator
75
+
76
+
77
+ mcp = FastMCP(
78
+ "network-aiops",
79
+ instructions=(
80
+ "Governed multi-vendor network device operations (preview) over NAPALM. "
81
+ f"Officially supported drivers: {_SUPPORTED} (Cisco IOS/IOS-XE, Nexus "
82
+ "NX-OS, IOS-XR, Arista EOS, Juniper Junos). Read tools: device facts, "
83
+ "interfaces, IP addresses, BGP/LLDP neighbors, ARP table, config backup, "
84
+ "and config diff (dry-run). Write tools: config merge, config replace, "
85
+ "config rollback. An optional NetBox block adds source-of-truth device "
86
+ "lookups. A 'target' selects a device from config. Every tool runs "
87
+ "through the network-aiops governance harness (audit / budget / risk-tier "
88
+ "/ undo). Need another platform (Nokia SR OS, Huawei VRP) or action? "
89
+ "Request it via a GitHub issue or PR."
90
+ ),
91
+ )
92
+
93
+ _conn_mgr: Optional[ConnectionManager] = None
94
+
95
+
96
+ def _manager() -> ConnectionManager:
97
+ """Return the connection manager, lazily initialising it from config."""
98
+ global _conn_mgr # noqa: PLW0603
99
+ if _conn_mgr is None:
100
+ config_path_str = os.environ.get("NETWORK_AIOPS_CONFIG")
101
+ config_path = Path(config_path_str) if config_path_str else None
102
+ _conn_mgr = ConnectionManager(load_config(config_path))
103
+ return _conn_mgr
104
+
105
+
106
+ def _target(name: Optional[str] = None) -> Any:
107
+ """Resolve a device target by name (or the default device)."""
108
+ return _manager().target(name)
109
+
110
+
111
+ def _netbox() -> Any:
112
+ """Return a NetBox client (raises a teaching NetworkApiError if unconfigured)."""
113
+ return _manager().netbox()
mcp_server/server.py ADDED
@@ -0,0 +1,33 @@
1
+ """MCP server wrapping network-aiops operations (stdio transport).
2
+
3
+ Thin adapter layer: each ``@mcp.tool()`` function (in ``mcp_server/tools/``)
4
+ delegates to the ``network_aiops`` ops package and is wrapped with the
5
+ network-aiops ``@governed_tool`` harness (audit / budget / undo / risk-tier).
6
+
7
+ Standalone, self-governed network device operations (preview) over NAPALM —
8
+ Cisco IOS/IOS-XE, Nexus NX-OS, IOS-XR, Arista EOS, Juniper Junos, plus optional
9
+ NetBox source-of-truth.
10
+
11
+ Source: https://github.com/AIops-tools/Network-AIops
12
+ License: MIT
13
+ """
14
+
15
+ import logging
16
+
17
+ from mcp_server._shared import _safe_error, mcp, tool_errors
18
+
19
+ # Importing the tool modules registers every @mcp.tool() onto the shared
20
+ # `mcp` instance. Order does not matter; each module is self-contained.
21
+ from mcp_server.tools import ( # noqa: F401 — side effects
22
+ config_ops,
23
+ facts,
24
+ netbox,
25
+ )
26
+
27
+ __all__ = ["mcp", "main", "_safe_error", "tool_errors"]
28
+
29
+
30
+ def main() -> None:
31
+ """Run the MCP server over stdio."""
32
+ logging.basicConfig(level=logging.INFO)
33
+ mcp.run(transport="stdio")
File without changes
@@ -0,0 +1,107 @@
1
+ """Config MCP tools: backup + diff (read/dry-run), merge + replace + rollback (write).
2
+
3
+ Every tool is wrapped with ``@governed_tool`` (the network-aiops harness):
4
+ policy pre-check, budget/runaway guard, risk-tier gate, audit logging to
5
+ ~/.network-aiops/audit.db, and undo-token recording. ``config_merge`` and
6
+ ``config_replace`` capture the pre-change running config and pass an ``undo=``
7
+ lambda so the harness records a ``config_replace``-to-backup reversal (the
8
+ device must support config replace for the undo to apply). ``config_rollback``
9
+ records no undo.
10
+ """
11
+
12
+ from typing import Optional
13
+
14
+ from mcp_server._shared import _target, mcp, tool_errors
15
+ from network_aiops.governance import governed_tool
16
+ from network_aiops.ops import config_ops as ops
17
+
18
+
19
+ def _restore_undo(params: dict, result) -> Optional[dict]:
20
+ """Build the inverse of a committed change: replace config back to the backup."""
21
+ if not isinstance(result, dict) or "backup" not in result:
22
+ return None
23
+ return {
24
+ "tool": "config_replace",
25
+ "params": {"target": params.get("target"), "config_text": result["backup"]},
26
+ "skill": "network-aiops",
27
+ "note": (
28
+ "Inverse: restore the captured pre-change running config via "
29
+ "config_replace. The device must support config replace."
30
+ ),
31
+ }
32
+
33
+
34
+ @mcp.tool()
35
+ @governed_tool(risk_level="low")
36
+ @tool_errors("dict")
37
+ def config_backup(target: Optional[str] = None) -> dict:
38
+ """[READ] Return the device running config (a note explains how to save it).
39
+
40
+ Args:
41
+ target: Device name from config; omit to use the default device.
42
+ """
43
+ return ops.config_backup(_target(target))
44
+
45
+
46
+ @mcp.tool()
47
+ @governed_tool(risk_level="low")
48
+ @tool_errors("dict")
49
+ def config_diff(
50
+ config_text: str, replace: bool = False, target: Optional[str] = None
51
+ ) -> dict:
52
+ """[READ] DRY-RUN: stage a candidate, return the diff, then discard it.
53
+
54
+ Nothing is committed. This is the dry-run primitive for previewing a change.
55
+
56
+ Args:
57
+ config_text: The configuration snippet (merge) or full config (replace).
58
+ replace: True to diff as a full-config replacement; False (default) to merge.
59
+ target: Device name from config.
60
+ """
61
+ return ops.config_diff(_target(target), config_text, replace=replace)
62
+
63
+
64
+ @mcp.tool()
65
+ @governed_tool(risk_level="medium", undo=_restore_undo)
66
+ @tool_errors("dict")
67
+ def config_merge(config_text: str, target: Optional[str] = None) -> dict:
68
+ """[WRITE] Merge a config snippet and commit. Captures running config for undo.
69
+
70
+ Returns ``diff`` (what changed) and ``backup`` (pre-change running config).
71
+ The recorded undo restores ``backup`` via config_replace.
72
+
73
+ Args:
74
+ config_text: The configuration snippet to merge.
75
+ target: Device name from config.
76
+ """
77
+ return ops.config_merge(_target(target), config_text)
78
+
79
+
80
+ @mcp.tool()
81
+ @governed_tool(risk_level="high", undo=_restore_undo)
82
+ @tool_errors("dict")
83
+ def config_replace(config_text: str, target: Optional[str] = None) -> dict:
84
+ """[WRITE] Replace the full config and commit. HIGH RISK. Captures running for undo.
85
+
86
+ Returns ``diff`` and ``backup``. The recorded undo replaces the config back
87
+ to ``backup`` (the device must support config replace).
88
+
89
+ Args:
90
+ config_text: The full replacement configuration.
91
+ target: Device name from config.
92
+ """
93
+ return ops.config_replace(_target(target), config_text)
94
+
95
+
96
+ @mcp.tool()
97
+ @governed_tool(risk_level="medium")
98
+ @tool_errors("dict")
99
+ def config_rollback(target: Optional[str] = None) -> dict:
100
+ """[WRITE] Revert the last committed change via NAPALM rollback(). No undo.
101
+
102
+ Device support varies (rollback depth is platform-dependent).
103
+
104
+ Args:
105
+ target: Device name from config.
106
+ """
107
+ return ops.config_rollback(_target(target))
@@ -0,0 +1,87 @@
1
+ """Read-only device facts MCP tools (NAPALM getters).
2
+
3
+ Every tool is wrapped with ``@governed_tool`` (the network-aiops harness):
4
+ policy pre-check, budget/runaway guard, graduated-autonomy risk-tier gate, and
5
+ audit logging to ~/.network-aiops/audit.db. These are all READ tools (no undo).
6
+ """
7
+
8
+ from typing import Optional
9
+
10
+ from mcp_server._shared import _target, mcp, tool_errors
11
+ from network_aiops.governance import governed_tool
12
+ from network_aiops.ops import facts as ops
13
+
14
+
15
+ @mcp.tool()
16
+ @governed_tool(risk_level="low")
17
+ @tool_errors("dict")
18
+ def device_facts(target: Optional[str] = None) -> dict:
19
+ """[READ] Core device facts: hostname, vendor, model, OS version, serial, uptime.
20
+
21
+ Also returns the interface name list. Use get_interfaces for per-interface
22
+ state/speed.
23
+
24
+ Args:
25
+ target: Device name from config; omit to use the default device.
26
+ """
27
+ return ops.device_facts(_target(target))
28
+
29
+
30
+ @mcp.tool()
31
+ @governed_tool(risk_level="low")
32
+ @tool_errors("list")
33
+ def get_interfaces(target: Optional[str] = None) -> list:
34
+ """[READ] Interfaces with up/down state, enabled flag, speed, and description.
35
+
36
+ Args:
37
+ target: Device name from config.
38
+ """
39
+ return ops.get_interfaces(_target(target))
40
+
41
+
42
+ @mcp.tool()
43
+ @governed_tool(risk_level="low")
44
+ @tool_errors("list")
45
+ def get_interfaces_ip(target: Optional[str] = None) -> list:
46
+ """[READ] Per-interface IPv4/IPv6 addresses and prefix lengths.
47
+
48
+ Args:
49
+ target: Device name from config.
50
+ """
51
+ return ops.get_interfaces_ip(_target(target))
52
+
53
+
54
+ @mcp.tool()
55
+ @governed_tool(risk_level="low")
56
+ @tool_errors("list")
57
+ def get_bgp_neighbors(target: Optional[str] = None) -> list:
58
+ """[READ] BGP neighbors per VRF: peer, remote AS, up state, prefix counts.
59
+
60
+ Args:
61
+ target: Device name from config.
62
+ """
63
+ return ops.get_bgp_neighbors(_target(target))
64
+
65
+
66
+ @mcp.tool()
67
+ @governed_tool(risk_level="low")
68
+ @tool_errors("list")
69
+ def get_lldp_neighbors(target: Optional[str] = None) -> list:
70
+ """[READ] LLDP neighbors: local port, remote hostname, remote port.
71
+
72
+ Args:
73
+ target: Device name from config.
74
+ """
75
+ return ops.get_lldp_neighbors(_target(target))
76
+
77
+
78
+ @mcp.tool()
79
+ @governed_tool(risk_level="low")
80
+ @tool_errors("list")
81
+ def get_arp_table(target: Optional[str] = None) -> list:
82
+ """[READ] ARP table entries: interface, IP, MAC, age.
83
+
84
+ Args:
85
+ target: Device name from config.
86
+ """
87
+ return ops.get_arp_table(_target(target))
@@ -0,0 +1,39 @@
1
+ """NetBox source-of-truth MCP tools (read-only, optional).
2
+
3
+ Degrade gracefully: a clear ``NetworkApiError`` (surfaced via ``tool_errors``)
4
+ when NetBox is not configured, instead of an opaque traceback.
5
+ """
6
+
7
+ from typing import Optional
8
+
9
+ from mcp_server._shared import _netbox, mcp, tool_errors
10
+ from network_aiops.governance import governed_tool
11
+ from network_aiops.ops import netbox_ops as ops
12
+
13
+
14
+ @mcp.tool()
15
+ @governed_tool(risk_level="low")
16
+ @tool_errors("list")
17
+ def netbox_list_devices(name: Optional[str] = None, limit: int = 50) -> list:
18
+ """[READ] List NetBox devices (name, role, site, status, primary IP).
19
+
20
+ Requires a configured NetBox block. Use this to confirm intended state
21
+ before pushing config to a device.
22
+
23
+ Args:
24
+ name: Optional name filter (contains match).
25
+ limit: Maximum devices to return (default 50).
26
+ """
27
+ return ops.netbox_list_devices(_netbox(), limit=limit, name=name)
28
+
29
+
30
+ @mcp.tool()
31
+ @governed_tool(risk_level="low")
32
+ @tool_errors("dict")
33
+ def netbox_get_device(name: str) -> dict:
34
+ """[READ] Return a single NetBox device by exact name.
35
+
36
+ Args:
37
+ name: Exact NetBox device name (see netbox_list_devices).
38
+ """
39
+ return ops.netbox_get_device(_netbox(), name)
@@ -0,0 +1,11 @@
1
+ """network-aiops — governed multi-vendor network device operations for AI agents.
2
+
3
+ Standalone and self-contained: the governance harness (audit, token budget,
4
+ undo-token recording, graduated risk tiers, prompt-injection sanitize) is
5
+ bundled under ``network_aiops.governance`` — this package has no external
6
+ skill-family dependency. Devices are reached over NAPALM (Cisco IOS/IOS-XE,
7
+ Nexus NX-OS, IOS-XR, Arista EOS, Juniper Junos); an optional NetBox block adds
8
+ source-of-truth lookups. Preview: not yet full-coverage.
9
+ """
10
+
11
+ __version__ = "0.1.0"
@@ -0,0 +1,9 @@
1
+ """CLI package for network-aiops.
2
+
3
+ Re-exports ``app`` so the pyproject entry point
4
+ ``network-aiops = "network_aiops.cli:app"`` works unchanged.
5
+ """
6
+
7
+ from network_aiops.cli._root import app
8
+
9
+ __all__ = ["app"]
@@ -0,0 +1,84 @@
1
+ """Shared helpers for network-aiops CLI sub-modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ from collections.abc import Callable
7
+ from pathlib import Path
8
+ from typing import Annotated, Any
9
+
10
+ import typer
11
+ from rich.console import Console
12
+
13
+ console = Console()
14
+
15
+ # ─── Shared Option types ───────────────────────────────────────────────────
16
+
17
+ TargetOption = Annotated[
18
+ str | None, typer.Option("--target", "-t", help="Device name from config")
19
+ ]
20
+ DryRunOption = Annotated[
21
+ bool, typer.Option("--dry-run", help="Preview the diff without committing")
22
+ ]
23
+ OutputOption = Annotated[
24
+ Path | None, typer.Option("--output", "-o", help="Write output to a file")
25
+ ]
26
+
27
+
28
+ def _cli_error_types() -> tuple[type[BaseException], ...]:
29
+ """Exceptions translated to a one-line teaching error instead of a traceback."""
30
+ from network_aiops.connection import NetworkApiError
31
+
32
+ return (NetworkApiError, KeyError, OSError, ValueError)
33
+
34
+
35
+ def cli_errors(fn: Callable) -> Callable:
36
+ """Translate known exceptions into one red line + exit code 1."""
37
+
38
+ @functools.wraps(fn)
39
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
40
+ try:
41
+ return fn(*args, **kwargs)
42
+ except (typer.Exit, typer.Abort):
43
+ raise
44
+ except _cli_error_types() as e:
45
+ message = str(e)
46
+ if isinstance(e, KeyError):
47
+ message = f"Missing required key: {message}"
48
+ console.print(f"[red]Error: {message}[/]")
49
+ raise typer.Exit(1) from e
50
+
51
+ return wrapper
52
+
53
+
54
+ def get_manager(config_path: Path | None = None):
55
+ """Return a ConnectionManager built from config."""
56
+ from network_aiops.config import load_config
57
+ from network_aiops.connection import ConnectionManager
58
+
59
+ return ConnectionManager(load_config(config_path))
60
+
61
+
62
+ def read_config_text(path: Path) -> str:
63
+ """Read a config-snippet file for merge/replace/diff."""
64
+ return path.read_text()
65
+
66
+
67
+ def dry_run_print(*, operation: str, detail: str, parameters: dict | None = None) -> None:
68
+ """Print a dry-run preview header (the diff is printed by the caller)."""
69
+ console.print("\n[bold magenta][DRY-RUN] No changes will be committed.[/]")
70
+ console.print(f"[magenta] Operation: {operation}[/]")
71
+ console.print(f"[magenta] Detail: {detail}[/]")
72
+ for k, v in (parameters or {}).items():
73
+ console.print(f"[magenta] Param: {k} = {v}[/]")
74
+ console.print("[magenta] Run without --dry-run to commit.[/]\n")
75
+
76
+
77
+ def double_confirm(action: str, resource: str) -> None:
78
+ """Require two confirmations for a destructive operation."""
79
+ console.print(f"[bold yellow]⚠️ About to: {action} '{resource}'[/]")
80
+ typer.confirm(f"Confirm 1/2: {action} '{resource}'?", abort=True)
81
+ typer.confirm(
82
+ f"Confirm 2/2: really {action} '{resource}'? This may be disruptive.",
83
+ abort=True,
84
+ )
@@ -0,0 +1,50 @@
1
+ """Top-level Typer app: assembles sub-apps and top-level commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from network_aiops.cli.config import config_app
8
+ from network_aiops.cli.device import device_app
9
+ from network_aiops.cli.doctor import doctor_cmd
10
+ from network_aiops.cli.netbox import netbox_app
11
+
12
+ app = typer.Typer(
13
+ name="network-aiops",
14
+ help="Governed multi-vendor network device operations for AI agents (NAPALM).",
15
+ no_args_is_help=True,
16
+ )
17
+
18
+ app.add_typer(device_app, name="device")
19
+ app.add_typer(config_app, name="config")
20
+ app.add_typer(netbox_app, name="netbox")
21
+ app.command("doctor")(doctor_cmd)
22
+
23
+
24
+ @app.command("mcp")
25
+ def mcp_cmd() -> None:
26
+ """Start the MCP server (stdio transport).
27
+
28
+ Single-command entry point for MCP clients (does not go through uvx/PyPI
29
+ resolution at launch):
30
+ network-aiops mcp
31
+ """
32
+ import sys
33
+
34
+ if sys.version_info < (3, 11):
35
+ typer.echo(
36
+ f"ERROR: network-aiops requires Python >= 3.11 "
37
+ f"(got {sys.version_info.major}.{sys.version_info.minor}).\n"
38
+ f"Fix: uv python install 3.12 && "
39
+ f"uv tool install --python 3.12 --force network-aiops",
40
+ err=True,
41
+ )
42
+ raise typer.Exit(2)
43
+
44
+ from mcp_server.server import main as _mcp_main
45
+
46
+ _mcp_main()
47
+
48
+
49
+ if __name__ == "__main__":
50
+ app()
@@ -0,0 +1,111 @@
1
+ """``network-aiops config ...`` sub-commands (backup / diff / merge / replace / rollback)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+ from rich.console import Console
9
+
10
+ from network_aiops.cli._common import (
11
+ DryRunOption,
12
+ OutputOption,
13
+ TargetOption,
14
+ cli_errors,
15
+ double_confirm,
16
+ dry_run_print,
17
+ get_manager,
18
+ read_config_text,
19
+ )
20
+ from network_aiops.ops import config_ops
21
+
22
+ config_app = typer.Typer(help="Device configuration operations.", no_args_is_help=True)
23
+ console = Console()
24
+
25
+
26
+ def _resolve(target: str | None):
27
+ return get_manager().target(target)
28
+
29
+
30
+ @config_app.command("backup")
31
+ @cli_errors
32
+ def config_backup_cmd(target: TargetOption = None, output: OutputOption = None) -> None:
33
+ """Fetch the running config (optionally save it to a file with -o)."""
34
+ result = config_ops.config_backup(_resolve(target))
35
+ if output is not None:
36
+ Path(output).write_text(result["config"])
37
+ console.print(f"[green]Saved running config of {result['name']} -> {output}[/]")
38
+ else:
39
+ console.print(result["config"])
40
+
41
+
42
+ @config_app.command("diff")
43
+ @cli_errors
44
+ def config_diff_cmd(
45
+ config_file: Path,
46
+ target: TargetOption = None,
47
+ replace: bool = typer.Option(False, "--replace", help="Diff as a full replace"),
48
+ ) -> None:
49
+ """Dry-run: show the diff a config file would produce (nothing is committed)."""
50
+ text = read_config_text(config_file)
51
+ result = config_ops.config_diff(_resolve(target), text, replace=replace)
52
+ console.print(f"[bold]Diff ({result['mode']}, not committed):[/]")
53
+ console.print(result["diff"] or "[dim](no changes)[/]")
54
+
55
+
56
+ @config_app.command("merge")
57
+ @cli_errors
58
+ def config_merge_cmd(
59
+ config_file: Path,
60
+ target: TargetOption = None,
61
+ dry_run: DryRunOption = False,
62
+ ) -> None:
63
+ """Merge a config snippet and commit (double confirm; --dry-run shows the diff)."""
64
+ text = read_config_text(config_file)
65
+ tgt = _resolve(target)
66
+ if dry_run:
67
+ dry_run_print(operation="config_merge", detail=f"merge into {tgt.name}")
68
+ result = config_ops.config_diff(tgt, text, replace=False)
69
+ console.print(result["diff"] or "[dim](no changes)[/]")
70
+ return
71
+ double_confirm("merge config into", tgt.name)
72
+ result = config_ops.config_merge(tgt, text)
73
+ console.print(f"[green]Committed merge to {result['name']}[/]")
74
+ console.print(result["diff"] or "[dim](no changes)[/]")
75
+
76
+
77
+ @config_app.command("replace")
78
+ @cli_errors
79
+ def config_replace_cmd(
80
+ config_file: Path,
81
+ target: TargetOption = None,
82
+ dry_run: DryRunOption = False,
83
+ ) -> None:
84
+ """Replace the full config and commit (HIGH RISK — double confirm; --dry-run shows diff)."""
85
+ text = read_config_text(config_file)
86
+ tgt = _resolve(target)
87
+ if dry_run:
88
+ dry_run_print(operation="config_replace", detail=f"replace config of {tgt.name}")
89
+ result = config_ops.config_diff(tgt, text, replace=True)
90
+ console.print(result["diff"] or "[dim](no changes)[/]")
91
+ return
92
+ double_confirm("REPLACE config of", tgt.name)
93
+ result = config_ops.config_replace(tgt, text)
94
+ console.print(f"[green]Committed replace to {result['name']}[/]")
95
+ console.print(result["diff"] or "[dim](no changes)[/]")
96
+
97
+
98
+ @config_app.command("rollback")
99
+ @cli_errors
100
+ def config_rollback_cmd(
101
+ target: TargetOption = None,
102
+ dry_run: DryRunOption = False,
103
+ ) -> None:
104
+ """Revert the last committed change (double confirm; device support varies)."""
105
+ tgt = _resolve(target)
106
+ if dry_run:
107
+ dry_run_print(operation="config_rollback", detail=f"rollback {tgt.name}")
108
+ return
109
+ double_confirm("rollback last commit on", tgt.name)
110
+ config_ops.config_rollback(tgt)
111
+ console.print(f"[green]Rolled back the last commit on {tgt.name}[/]")