ags-cli 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.
Files changed (156) hide show
  1. ags_cli-0.1.0.dist-info/METADATA +332 -0
  2. ags_cli-0.1.0.dist-info/RECORD +156 -0
  3. ags_cli-0.1.0.dist-info/WHEEL +5 -0
  4. ags_cli-0.1.0.dist-info/entry_points.txt +2 -0
  5. ags_cli-0.1.0.dist-info/top_level.txt +2 -0
  6. cli/__init__.py +0 -0
  7. cli/__main__.py +4 -0
  8. cli/client.py +145 -0
  9. cli/commands/__init__.py +0 -0
  10. cli/commands/adapter.py +33 -0
  11. cli/commands/artifact.py +21 -0
  12. cli/commands/ask.py +212 -0
  13. cli/commands/chat.py +649 -0
  14. cli/commands/diff.py +49 -0
  15. cli/commands/doctor.py +93 -0
  16. cli/commands/gui.py +45 -0
  17. cli/commands/init.py +20 -0
  18. cli/commands/mcp.py +50 -0
  19. cli/commands/memory.py +65 -0
  20. cli/commands/model.py +73 -0
  21. cli/commands/policy.py +52 -0
  22. cli/commands/project.py +122 -0
  23. cli/commands/quota.py +107 -0
  24. cli/commands/run.py +84 -0
  25. cli/commands/serve.py +171 -0
  26. cli/commands/service.py +236 -0
  27. cli/commands/session.py +219 -0
  28. cli/commands/stats.py +96 -0
  29. cli/commands/status.py +36 -0
  30. cli/commands/task.py +37 -0
  31. cli/commands/usage.py +86 -0
  32. cli/local.py +84 -0
  33. cli/main.py +149 -0
  34. harness/__init__.py +4 -0
  35. harness/adapters/__init__.py +26 -0
  36. harness/adapters/antigravity.py +301 -0
  37. harness/adapters/claude_code.py +522 -0
  38. harness/adapters/local_openai.py +534 -0
  39. harness/adapters/mock.py +112 -0
  40. harness/adapters/probe.py +65 -0
  41. harness/adapters/registry.py +56 -0
  42. harness/adapters/warm_pool.py +255 -0
  43. harness/api/__init__.py +0 -0
  44. harness/api/router.py +38 -0
  45. harness/api/v1/__init__.py +0 -0
  46. harness/api/v1/adapters.py +105 -0
  47. harness/api/v1/approvals.py +173 -0
  48. harness/api/v1/artifacts.py +44 -0
  49. harness/api/v1/attachments.py +48 -0
  50. harness/api/v1/doctor.py +22 -0
  51. harness/api/v1/health.py +37 -0
  52. harness/api/v1/mcp.py +131 -0
  53. harness/api/v1/memory.py +80 -0
  54. harness/api/v1/policies.py +49 -0
  55. harness/api/v1/project.py +302 -0
  56. harness/api/v1/runs.py +98 -0
  57. harness/api/v1/sessions.py +248 -0
  58. harness/api/v1/stream.py +110 -0
  59. harness/api/v1/tasks.py +30 -0
  60. harness/api/v1/usage.py +58 -0
  61. harness/bootstrap.py +216 -0
  62. harness/db.py +164 -0
  63. harness/deps.py +58 -0
  64. harness/eventbus/__init__.py +20 -0
  65. harness/eventbus/inprocess_bus.py +85 -0
  66. harness/main.py +144 -0
  67. harness/mcp/__init__.py +22 -0
  68. harness/mcp/client.py +139 -0
  69. harness/mcp/config_gen.py +77 -0
  70. harness/mcp/registry.py +156 -0
  71. harness/mcp/tools.py +81 -0
  72. harness/memory/__init__.py +13 -0
  73. harness/memory/context_budget.py +116 -0
  74. harness/memory/embed.py +217 -0
  75. harness/memory/extract.py +74 -0
  76. harness/memory/ingest.py +106 -0
  77. harness/memory/namespaces.py +57 -0
  78. harness/memory/ranking.py +31 -0
  79. harness/memory/redact.py +37 -0
  80. harness/memory/service.py +320 -0
  81. harness/memory/sqlite_vec_backend.py +266 -0
  82. harness/memory/summarize.py +68 -0
  83. harness/models/__init__.py +35 -0
  84. harness/models/adapter_health.py +27 -0
  85. harness/models/approval.py +37 -0
  86. harness/models/artifact.py +35 -0
  87. harness/models/audit_log.py +33 -0
  88. harness/models/comparison.py +33 -0
  89. harness/models/enums.py +146 -0
  90. harness/models/mcp_server_config.py +49 -0
  91. harness/models/memory.py +76 -0
  92. harness/models/policy.py +29 -0
  93. harness/models/provider_config.py +35 -0
  94. harness/models/run.py +46 -0
  95. harness/models/run_event.py +35 -0
  96. harness/models/session.py +50 -0
  97. harness/models/task.py +30 -0
  98. harness/models/types.py +63 -0
  99. harness/models/workspace.py +27 -0
  100. harness/observability/__init__.py +4 -0
  101. harness/observability/audit.py +88 -0
  102. harness/observability/logging.py +47 -0
  103. harness/observability/metrics.py +43 -0
  104. harness/observability/tracing.py +38 -0
  105. harness/orchestrator/__init__.py +19 -0
  106. harness/orchestrator/engine.py +821 -0
  107. harness/orchestrator/handoff.py +33 -0
  108. harness/orchestrator/lifecycle.py +69 -0
  109. harness/orchestrator/native_resume.py +93 -0
  110. harness/orchestrator/policies.py +101 -0
  111. harness/orchestrator/reconcile.py +39 -0
  112. harness/orchestrator/run_graph.py +62 -0
  113. harness/orchestrator/snapshot.py +49 -0
  114. harness/schemas/__init__.py +1 -0
  115. harness/schemas/adapter.py +26 -0
  116. harness/schemas/approval.py +25 -0
  117. harness/schemas/artifact.py +17 -0
  118. harness/schemas/common.py +20 -0
  119. harness/schemas/memory.py +50 -0
  120. harness/schemas/policy.py +39 -0
  121. harness/schemas/run.py +116 -0
  122. harness/schemas/session.py +93 -0
  123. harness/schemas/task.py +24 -0
  124. harness/security/__init__.py +5 -0
  125. harness/security/approval_policy.py +107 -0
  126. harness/security/auth.py +106 -0
  127. harness/security/permissions.py +59 -0
  128. harness/security/risk.py +59 -0
  129. harness/security/secrets.py +49 -0
  130. harness/services/__init__.py +1 -0
  131. harness/services/adapters_health.py +212 -0
  132. harness/services/approvals.py +84 -0
  133. harness/services/artifacts.py +78 -0
  134. harness/services/attachments.py +228 -0
  135. harness/services/comparison.py +345 -0
  136. harness/services/doctor.py +156 -0
  137. harness/services/files.py +287 -0
  138. harness/services/mcp_servers.py +179 -0
  139. harness/services/project_git.py +101 -0
  140. harness/services/retention.py +97 -0
  141. harness/services/runs.py +155 -0
  142. harness/services/runs_diff.py +201 -0
  143. harness/services/sessions.py +242 -0
  144. harness/services/ssh.py +300 -0
  145. harness/services/stats.py +132 -0
  146. harness/services/tasks.py +41 -0
  147. harness/services/workspaces.py +184 -0
  148. harness/services/worktrees.py +439 -0
  149. harness/settings.py +186 -0
  150. harness/skills/__init__.py +11 -0
  151. harness/skills/manager.py +141 -0
  152. harness/tools/__init__.py +1 -0
  153. harness/tools/fs_tools.py +295 -0
  154. harness/workers/__init__.py +0 -0
  155. harness/workers/dispatch.py +26 -0
  156. harness/workers/inprocess.py +135 -0
cli/commands/run.py ADDED
@@ -0,0 +1,84 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ import typer
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+
9
+ from cli.client import HarnessClient
10
+
11
+ app = typer.Typer(help="Start runs and compare/merge outputs.")
12
+ console = Console()
13
+
14
+
15
+ @app.command("start")
16
+ def start(
17
+ objective: str = typer.Option(None, "--objective", "-o"),
18
+ task_id: str = typer.Option(None, "--task"),
19
+ workspace: str = typer.Option("default", "--workspace", "-w"),
20
+ policy: str = typer.Option("single", "--policy", "-p", help="Routing policy name."),
21
+ providers: str = typer.Option(
22
+ None, "--providers", help="Comma-separated provider override, e.g. mock,local."
23
+ ),
24
+ model: str = typer.Option(None, "--model", "-m", help="One-off model override for this run."),
25
+ inline: bool = typer.Option(
26
+ False, "--inline", help="Run synchronously in the API (no worker needed)."
27
+ ),
28
+ follow: bool = typer.Option(False, "--follow", "-f", help="Tail the first run live."),
29
+ ) -> None:
30
+ """Start a run for an objective or existing task."""
31
+ client = HarnessClient()
32
+ body = {
33
+ "objective": objective,
34
+ "task_id": task_id,
35
+ "workspace": workspace,
36
+ "policy": policy,
37
+ "providers": providers.split(",") if providers else None,
38
+ "model": model,
39
+ }
40
+ data = client.post("/runs/start", json=body, inline=inline)
41
+ console.print(f"[green]session[/green] {data['session_id']} dispatched={data['dispatched']}")
42
+ table = Table("run_id", "provider", "role", "status")
43
+ for r in data["runs"]:
44
+ table.add_row(r["id"], r["adapter_kind"], r["role"], r["status"])
45
+ console.print(table)
46
+
47
+ if follow and data["runs"]:
48
+ run_id = data["runs"][0]["id"]
49
+ console.print(f"[dim]— streaming run {run_id} —[/dim]")
50
+ for frame in client.stream(run_id):
51
+ try:
52
+ ev = json.loads(frame)
53
+ except json.JSONDecodeError:
54
+ continue
55
+ if ev.get("type") == "token":
56
+ console.print(ev.get("text", ""), end="")
57
+ elif ev.get("type") == "status":
58
+ console.print(f"\n[cyan][{ev.get('status')}][/cyan]")
59
+
60
+
61
+ @app.command("compare")
62
+ def compare(
63
+ run_ids: str = typer.Argument(..., help="Comma-separated run ids."),
64
+ strategy: str = typer.Option("judge", "--strategy", "-s"),
65
+ ) -> None:
66
+ """Compare two or more runs side by side."""
67
+ client = HarnessClient()
68
+ data = client.post(
69
+ "/runs/compare", json={"run_ids": run_ids.split(","), "strategy": strategy}
70
+ )
71
+ console.print_json(data=data["result"])
72
+
73
+
74
+ @app.command("merge")
75
+ def merge(
76
+ run_ids: str = typer.Argument(...),
77
+ persist: bool = typer.Option(True, "--persist/--no-persist"),
78
+ ) -> None:
79
+ """Merge runs into a canonical answer (optionally persisted as memory)."""
80
+ client = HarnessClient()
81
+ data = client.post("/runs/merge", json={"run_ids": run_ids.split(","), "persist": persist})
82
+ console.print(data["result"].get("merged_content", ""))
83
+ if data.get("canonical_memory_item_id"):
84
+ console.print(f"\n[green]persisted as memory[/green] {data['canonical_memory_item_id']}")
cli/commands/serve.py ADDED
@@ -0,0 +1,171 @@
1
+ """``ags serve`` — run the daemonless server (API + in-process worker).
2
+
3
+ A uvicorn server backed by SQLite, with run execution happening in-process.
4
+ The CLI auto-spawns this on first use; you can also run it in the foreground for logs."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import contextlib
9
+ import os
10
+
11
+ import typer
12
+
13
+
14
+ def _uvicorn_log_config(log_file) -> dict | None:
15
+ """A uvicorn logging config that also writes to ``~/.ags/server.log``.
16
+
17
+ When the server runs as the Tauri sidecar, its stdout/stderr go to the (hidden)
18
+ desktop-app process and are invisible on Windows — a startup crash (e.g. a failed
19
+ ASGI import) then shows up only as a perpetually-loading GUI. Feeding uvicorn a
20
+ log config with an extra file handler means those errors land in a file the user
21
+ can open and send us. Built from uvicorn's own defaults so console output is
22
+ unchanged. Best-effort: returns None if uvicorn's config can't be read."""
23
+ try:
24
+ import copy
25
+
26
+ from uvicorn.config import LOGGING_CONFIG
27
+
28
+ log_file.parent.mkdir(parents=True, exist_ok=True)
29
+ cfg = copy.deepcopy(LOGGING_CONFIG)
30
+ cfg.setdefault("formatters", {})["plainfile"] = {
31
+ "format": "%(asctime)s %(levelname)s %(name)s: %(message)s",
32
+ }
33
+ cfg.setdefault("handlers", {})["logfile"] = {
34
+ "class": "logging.FileHandler",
35
+ "filename": str(log_file),
36
+ "formatter": "plainfile",
37
+ "encoding": "utf-8",
38
+ }
39
+ # uvicorn.error propagates to the "uvicorn" logger, so adding the file handler
40
+ # there (plus access) captures startup/runtime errors without duplication.
41
+ for name in ("uvicorn", "uvicorn.access"):
42
+ lg = cfg.setdefault("loggers", {}).setdefault(name, {})
43
+ lg["handlers"] = list(dict.fromkeys([*lg.get("handlers", []), "logfile"]))
44
+ return cfg
45
+ except Exception: # noqa: BLE001 (diagnostics only — never fail startup)
46
+ return None
47
+
48
+ # Default bind address. Loopback-only unless HARNESS_API_HOST is set (e.g. 0.0.0.0 to
49
+ # expose the GUI/API to the LAN). Reading the env here means the auto-spawned server
50
+ # (from `ags gui`) inherits it too — `HARNESS_API_HOST=0.0.0.0 ags gui` binds the LAN.
51
+ _DEFAULT_HOST = os.environ.get("HARNESS_API_HOST", "127.0.0.1")
52
+
53
+ # Explicit acknowledgement required to bind a non-loopback address. The API has no auth
54
+ # and exposes destructive, project-scoped file operations (write/create/rename/delete),
55
+ # so binding it to the LAN hands any host on the network unauthenticated write access to
56
+ # the project folder. Loopback is enforced unless the operator opts in on purpose.
57
+ _INSECURE_BIND_ENV = "HARNESS_ALLOW_INSECURE_BIND"
58
+
59
+
60
+ def _is_loopback(host: str) -> bool:
61
+ """True when ``host`` binds only the local machine. Covers the literal loopback
62
+ addresses, the ``127.0.0.0/8`` range, and the ``localhost`` hostname. Anything
63
+ else (``0.0.0.0``, ``::``, a routable IP/name) is treated as non-loopback."""
64
+ import ipaddress
65
+
66
+ h = host.strip().strip("[]").lower() # tolerate bracketed IPv6
67
+ if h in ("localhost", "localhost.localdomain"):
68
+ return True
69
+ try:
70
+ return ipaddress.ip_address(h).is_loopback
71
+ except ValueError:
72
+ return False
73
+
74
+
75
+ def _log_startup_crash(log_file) -> None:
76
+ """Best-effort plain-file crash log for a failure BEFORE uvicorn's own logging is
77
+ configured — e.g. an import-time crash on ``from harness.main import app``, which
78
+ happens before ``uvicorn.run()`` is even called, so ``_uvicorn_log_config``'s file
79
+ handler (wired up inside uvicorn's own config) never gets installed. As the Tauri
80
+ sidecar this process has no console at all (release builds are windowed —
81
+ ``windows_subsystem = "windows"``), so without this, an uncaught exception here
82
+ vanishes with zero trace and the GUI just sees a perpetually-loading API. Deliberately
83
+ plain file I/O, not the logging module — that's exactly what might not be set up yet."""
84
+ import traceback
85
+
86
+ with contextlib.suppress(Exception):
87
+ log_file.parent.mkdir(parents=True, exist_ok=True)
88
+ with open(log_file, "a", encoding="utf-8") as f:
89
+ f.write("\n--- ags serve crashed at startup ---\n")
90
+ f.write(traceback.format_exc())
91
+
92
+
93
+ def _force_utf8_streams() -> None:
94
+ """Reconfigure stdout/stderr to UTF-8 so Unicode output can't crash the process.
95
+
96
+ When the Tauri sidecar spawns ``ags serve`` its stdout/stderr are pipes, not a
97
+ console — so on Windows Python defaults them to the legacy ``cp1252`` (``charmap``)
98
+ codec, which can't encode common non-ASCII characters. A single Unicode char in a
99
+ log line (or the startup banner) then raises ``UnicodeEncodeError`` and kills the
100
+ whole server before uvicorn even binds — the GUI only sees a dead backend. This
101
+ ran fine when launched manually because a real terminal uses a capable codepage.
102
+ ``errors='replace'`` is belt-and-suspenders so nothing downstream can ever throw."""
103
+ import sys
104
+
105
+ for stream in (sys.stdout, sys.stderr):
106
+ reconfigure = getattr(stream, "reconfigure", None)
107
+ if reconfigure is not None:
108
+ with contextlib.suppress(Exception):
109
+ reconfigure(encoding="utf-8", errors="replace")
110
+
111
+
112
+ def _run_uvicorn(host: str, port: int, log_file) -> None:
113
+ import uvicorn
114
+
115
+ log_config = _uvicorn_log_config(log_file)
116
+ # Import the ASGI app and hand uvicorn the OBJECT, not the "harness.main:app"
117
+ # import string. In a PyInstaller bundle (the Tauri sidecar), uvicorn's re-import
118
+ # of that string fails with "Could not import module 'harness.main'" and the
119
+ # server never binds — which the GUI only sees as a perpetually-loading API. A
120
+ # single-worker, no-reload server doesn't need the import string, so passing the
121
+ # object works identically from source and frozen.
122
+ from harness.main import app as asgi_app
123
+
124
+ uvicorn.run(asgi_app, host=host, port=port, log_level="warning",
125
+ **({"log_config": log_config} if log_config else {}))
126
+
127
+
128
+ def serve(
129
+ host: str = typer.Option(
130
+ _DEFAULT_HOST, "--host",
131
+ help=f"Bind address. Loopback only unless {_INSECURE_BIND_ENV}=1 is set.",
132
+ ),
133
+ port: int = typer.Option(8770, "--port", "-p", help="Bind port."),
134
+ ) -> None:
135
+ """Start the local AgentS server in the foreground."""
136
+ _force_utf8_streams() # must run before any output — see the helper's docstring
137
+
138
+ # Refuse to expose the unauthenticated, file-mutating API beyond this machine unless
139
+ # the operator has explicitly acknowledged the risk. Keeps `ags serve` and the
140
+ # auto-spawned GUI sidecar safe-by-default while preserving a deliberate LAN opt-in.
141
+ if not _is_loopback(host) and os.environ.get(_INSECURE_BIND_ENV) not in ("1", "true", "yes"):
142
+ with contextlib.suppress(Exception):
143
+ print(
144
+ f"Refusing to bind non-loopback host {host!r}: the AgentS API has no "
145
+ f"authentication and can read/write/delete files in the project folder. "
146
+ f"Set {_INSECURE_BIND_ENV}=1 to override (only on a trusted network).",
147
+ flush=True,
148
+ )
149
+ raise typer.Exit(2)
150
+
151
+ from cli.local import LOG_FILE, PID_FILE, _write_api_url
152
+
153
+ base_url = f"http://localhost:{port}"
154
+ PID_FILE.parent.mkdir(parents=True, exist_ok=True)
155
+ PID_FILE.write_text(str(os.getpid()))
156
+ _write_api_url(base_url) # record so `ags` in any terminal finds this server
157
+
158
+ # Plain ASCII banner via print(), not the rich Console: rich caches the stream's
159
+ # (legacy cp1252) encoding at construction — before _force_utf8_streams runs — and
160
+ # its legacy-Windows renderer would still choke on a non-ASCII glyph like the old
161
+ # "▶". ASCII text through a UTF-8 stream can't raise; wrapped anyway for safety.
162
+ with contextlib.suppress(Exception):
163
+ print(f"AgentS (local) serving on {base_url} (Ctrl-C to stop)", flush=True)
164
+ try:
165
+ _run_uvicorn(host, port, LOG_FILE)
166
+ except Exception:
167
+ _log_startup_crash(LOG_FILE)
168
+ raise
169
+ finally:
170
+ with contextlib.suppress(FileNotFoundError):
171
+ PID_FILE.unlink()
@@ -0,0 +1,236 @@
1
+ """`ags service install` and `ags service uninstall` — supervised local server.
2
+
3
+ Writes systemd user unit (Linux) or launchd plist (macOS) to auto-start the
4
+ server on login. Idempotent; refuses to overwrite a file it didn't create.
5
+
6
+ ags service install # write ~/.config/systemd/user/ags.service (Linux)
7
+ # or ~/Library/LaunchAgents/com.ags.serve.plist (macOS)
8
+ ags service uninstall # remove the service unit/plist
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import shutil
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ import typer
19
+ from rich.console import Console
20
+
21
+ console = Console()
22
+
23
+ app = typer.Typer(help="Install or uninstall the local server as a system service.", no_args_is_help=True)
24
+
25
+
26
+ @app.callback(invoke_without_command=True)
27
+ def _check_platform(ctx: typer.Context) -> None:
28
+ """Check platform support on entry; fail early for unsupported platforms."""
29
+ if not (sys.platform.startswith("linux") or sys.platform == "darwin"):
30
+ console.print(
31
+ f"[red]✗ Unsupported platform: {sys.platform}[/red]\n"
32
+ "[yellow]Only Linux (systemd) and macOS (launchd) are supported.[/yellow]"
33
+ )
34
+ raise typer.Exit(1)
35
+
36
+ # Markers to detect managed files
37
+ _SYSTEMD_MARKER = "# managed by ags"
38
+ _LAUNCHD_MARKER = "<!-- managed by ags -->"
39
+
40
+
41
+ def _get_systemd_path() -> Path:
42
+ """Return the path to the systemd user unit file."""
43
+ home = Path.home()
44
+ return home / ".config" / "systemd" / "user" / "ags.service"
45
+
46
+
47
+ def _get_launchd_path() -> Path:
48
+ """Return the path to the launchd plist file."""
49
+ home = Path.home()
50
+ return home / "Library" / "LaunchAgents" / "com.ags.serve.plist"
51
+
52
+
53
+ def _get_ags_path() -> str | None:
54
+ """Resolve the ags binary path via shutil.which(); return None if not found."""
55
+ return shutil.which("ags")
56
+
57
+
58
+ def _is_managed_systemd(path: Path) -> bool:
59
+ """Check if a systemd unit file was created by ags (has the marker)."""
60
+ if not path.exists():
61
+ return False
62
+ try:
63
+ return _SYSTEMD_MARKER in path.read_text()
64
+ except Exception:
65
+ return False
66
+
67
+
68
+ def _is_managed_launchd(path: Path) -> bool:
69
+ """Check if a launchd plist was created by ags (has the marker)."""
70
+ if not path.exists():
71
+ return False
72
+ try:
73
+ return _LAUNCHD_MARKER in path.read_text()
74
+ except Exception:
75
+ return False
76
+
77
+
78
+ def _write_systemd_unit(ags_path: str, unit_path: Path) -> None:
79
+ """Write the systemd user service unit."""
80
+ unit_path.parent.mkdir(parents=True, exist_ok=True)
81
+
82
+ # Build the Environment line if HARNESS_API_HOST is set
83
+ env_line = ""
84
+ if "HARNESS_API_HOST" in os.environ:
85
+ env_line = f'Environment="HARNESS_API_HOST={os.environ["HARNESS_API_HOST"]}"\n'
86
+
87
+ content = f"""{_SYSTEMD_MARKER}
88
+ [Unit]
89
+ Description=AgentS local server
90
+
91
+ [Service]
92
+ ExecStart="{ags_path}" serve
93
+ Restart=on-failure
94
+ {env_line}[Install]
95
+ WantedBy=default.target
96
+ """
97
+
98
+ unit_path.write_text(content)
99
+
100
+
101
+ def _write_launchd_plist(ags_path: str, plist_path: Path) -> None:
102
+ """Write the launchd plist."""
103
+ plist_path.parent.mkdir(parents=True, exist_ok=True)
104
+
105
+ plist_content = f"""<?xml version="1.0" encoding="UTF-8"?>
106
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
107
+ <plist version="1.0">
108
+ {_LAUNCHD_MARKER}
109
+ <dict>
110
+ <key>Label</key>
111
+ <string>com.ags.serve</string>
112
+ <key>ProgramArguments</key>
113
+ <array>
114
+ <string>{ags_path}</string>
115
+ <string>serve</string>
116
+ </array>
117
+ <key>RunAtLoad</key>
118
+ <true/>
119
+ <key>KeepAlive</key>
120
+ <dict>
121
+ <key>SuccessfulExit</key>
122
+ <false/>
123
+ </dict>
124
+ </dict>
125
+ </plist>
126
+ """
127
+
128
+ plist_path.write_text(plist_content)
129
+
130
+
131
+ @app.command("install")
132
+ def install() -> None:
133
+ """Install the local server as a system service (systemd / launchd)."""
134
+ ags_path = _get_ags_path()
135
+ if not ags_path:
136
+ console.print("[red]✗ ags not found on PATH[/red]")
137
+ raise typer.Exit(1)
138
+
139
+ if sys.platform.startswith("linux"):
140
+ unit_path = _get_systemd_path()
141
+
142
+ # Check if file exists and is not managed by ags
143
+ was_installed = unit_path.exists() and _is_managed_systemd(unit_path)
144
+ if unit_path.exists() and not _is_managed_systemd(unit_path):
145
+ console.print(
146
+ f"[red]✗ {unit_path} already exists but is not managed by ags.[/red]\n"
147
+ "[yellow]Remove it or use a different configuration.[/yellow]"
148
+ )
149
+ raise typer.Exit(1)
150
+
151
+ # Write the unit file
152
+ _write_systemd_unit(ags_path, unit_path)
153
+
154
+ # Print info
155
+ if was_installed:
156
+ console.print("[green]✓ Service already installed — refreshed[/green]")
157
+ else:
158
+ console.print("[green]✓ Service installed[/green]")
159
+
160
+ console.print(
161
+ "\n[dim]Run this to enable and start the service:[/dim]\n"
162
+ "[bold]systemctl --user daemon-reload && systemctl --user enable --now ags[/bold]"
163
+ )
164
+
165
+ elif sys.platform == "darwin":
166
+ plist_path = _get_launchd_path()
167
+
168
+ # Check if file exists and is not managed by ags
169
+ was_installed = plist_path.exists() and _is_managed_launchd(plist_path)
170
+ if plist_path.exists() and not _is_managed_launchd(plist_path):
171
+ console.print(
172
+ f"[red]✗ {plist_path} already exists but is not managed by ags.[/red]\n"
173
+ "[yellow]Remove it or use a different configuration.[/yellow]"
174
+ )
175
+ raise typer.Exit(1)
176
+
177
+ # Write the plist
178
+ _write_launchd_plist(ags_path, plist_path)
179
+
180
+ # Print info
181
+ if was_installed:
182
+ console.print("[green]✓ Service already installed — refreshed[/green]")
183
+ else:
184
+ console.print("[green]✓ Service installed[/green]")
185
+
186
+ console.print(
187
+ "\n[dim]Run this to load the service:[/dim]\n"
188
+ f"[bold]launchctl bootstrap gui/$(id -u) {plist_path}[/bold]"
189
+ )
190
+
191
+
192
+
193
+ @app.command("uninstall")
194
+ def uninstall() -> None:
195
+ """Remove the local server service unit (systemd / launchd)."""
196
+ if sys.platform.startswith("linux"):
197
+ unit_path = _get_systemd_path()
198
+
199
+ if not unit_path.exists():
200
+ console.print("[dim]Service not installed[/dim]")
201
+ raise typer.Exit(0)
202
+
203
+ if not _is_managed_systemd(unit_path):
204
+ console.print(
205
+ f"[red]✗ {unit_path} exists but is not managed by ags.[/red]\n"
206
+ "[yellow]Remove it manually if you want to delete it.[/yellow]"
207
+ )
208
+ raise typer.Exit(1)
209
+
210
+ unit_path.unlink()
211
+ console.print("[green]✓ Service uninstalled[/green]")
212
+ console.print(
213
+ "\n[dim]Run this to disable the service:[/dim]\n"
214
+ "[bold]systemctl --user daemon-reload && systemctl --user disable ags[/bold]"
215
+ )
216
+
217
+ elif sys.platform == "darwin":
218
+ plist_path = _get_launchd_path()
219
+
220
+ if not plist_path.exists():
221
+ console.print("[dim]Service not installed[/dim]")
222
+ raise typer.Exit(0)
223
+
224
+ if not _is_managed_launchd(plist_path):
225
+ console.print(
226
+ f"[red]✗ {plist_path} exists but is not managed by ags.[/red]\n"
227
+ "[yellow]Remove it manually if you want to delete it.[/yellow]"
228
+ )
229
+ raise typer.Exit(1)
230
+
231
+ plist_path.unlink()
232
+ console.print("[green]✓ Service uninstalled[/green]")
233
+ console.print(
234
+ "\n[dim]Run this to unload the service:[/dim]\n"
235
+ "[bold]launchctl bootout gui/$(id -u)/com.ags.serve[/bold]"
236
+ )