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.
- ags_cli-0.1.0.dist-info/METADATA +332 -0
- ags_cli-0.1.0.dist-info/RECORD +156 -0
- ags_cli-0.1.0.dist-info/WHEEL +5 -0
- ags_cli-0.1.0.dist-info/entry_points.txt +2 -0
- ags_cli-0.1.0.dist-info/top_level.txt +2 -0
- cli/__init__.py +0 -0
- cli/__main__.py +4 -0
- cli/client.py +145 -0
- cli/commands/__init__.py +0 -0
- cli/commands/adapter.py +33 -0
- cli/commands/artifact.py +21 -0
- cli/commands/ask.py +212 -0
- cli/commands/chat.py +649 -0
- cli/commands/diff.py +49 -0
- cli/commands/doctor.py +93 -0
- cli/commands/gui.py +45 -0
- cli/commands/init.py +20 -0
- cli/commands/mcp.py +50 -0
- cli/commands/memory.py +65 -0
- cli/commands/model.py +73 -0
- cli/commands/policy.py +52 -0
- cli/commands/project.py +122 -0
- cli/commands/quota.py +107 -0
- cli/commands/run.py +84 -0
- cli/commands/serve.py +171 -0
- cli/commands/service.py +236 -0
- cli/commands/session.py +219 -0
- cli/commands/stats.py +96 -0
- cli/commands/status.py +36 -0
- cli/commands/task.py +37 -0
- cli/commands/usage.py +86 -0
- cli/local.py +84 -0
- cli/main.py +149 -0
- harness/__init__.py +4 -0
- harness/adapters/__init__.py +26 -0
- harness/adapters/antigravity.py +301 -0
- harness/adapters/claude_code.py +522 -0
- harness/adapters/local_openai.py +534 -0
- harness/adapters/mock.py +112 -0
- harness/adapters/probe.py +65 -0
- harness/adapters/registry.py +56 -0
- harness/adapters/warm_pool.py +255 -0
- harness/api/__init__.py +0 -0
- harness/api/router.py +38 -0
- harness/api/v1/__init__.py +0 -0
- harness/api/v1/adapters.py +105 -0
- harness/api/v1/approvals.py +173 -0
- harness/api/v1/artifacts.py +44 -0
- harness/api/v1/attachments.py +48 -0
- harness/api/v1/doctor.py +22 -0
- harness/api/v1/health.py +37 -0
- harness/api/v1/mcp.py +131 -0
- harness/api/v1/memory.py +80 -0
- harness/api/v1/policies.py +49 -0
- harness/api/v1/project.py +302 -0
- harness/api/v1/runs.py +98 -0
- harness/api/v1/sessions.py +248 -0
- harness/api/v1/stream.py +110 -0
- harness/api/v1/tasks.py +30 -0
- harness/api/v1/usage.py +58 -0
- harness/bootstrap.py +216 -0
- harness/db.py +164 -0
- harness/deps.py +58 -0
- harness/eventbus/__init__.py +20 -0
- harness/eventbus/inprocess_bus.py +85 -0
- harness/main.py +144 -0
- harness/mcp/__init__.py +22 -0
- harness/mcp/client.py +139 -0
- harness/mcp/config_gen.py +77 -0
- harness/mcp/registry.py +156 -0
- harness/mcp/tools.py +81 -0
- harness/memory/__init__.py +13 -0
- harness/memory/context_budget.py +116 -0
- harness/memory/embed.py +217 -0
- harness/memory/extract.py +74 -0
- harness/memory/ingest.py +106 -0
- harness/memory/namespaces.py +57 -0
- harness/memory/ranking.py +31 -0
- harness/memory/redact.py +37 -0
- harness/memory/service.py +320 -0
- harness/memory/sqlite_vec_backend.py +266 -0
- harness/memory/summarize.py +68 -0
- harness/models/__init__.py +35 -0
- harness/models/adapter_health.py +27 -0
- harness/models/approval.py +37 -0
- harness/models/artifact.py +35 -0
- harness/models/audit_log.py +33 -0
- harness/models/comparison.py +33 -0
- harness/models/enums.py +146 -0
- harness/models/mcp_server_config.py +49 -0
- harness/models/memory.py +76 -0
- harness/models/policy.py +29 -0
- harness/models/provider_config.py +35 -0
- harness/models/run.py +46 -0
- harness/models/run_event.py +35 -0
- harness/models/session.py +50 -0
- harness/models/task.py +30 -0
- harness/models/types.py +63 -0
- harness/models/workspace.py +27 -0
- harness/observability/__init__.py +4 -0
- harness/observability/audit.py +88 -0
- harness/observability/logging.py +47 -0
- harness/observability/metrics.py +43 -0
- harness/observability/tracing.py +38 -0
- harness/orchestrator/__init__.py +19 -0
- harness/orchestrator/engine.py +821 -0
- harness/orchestrator/handoff.py +33 -0
- harness/orchestrator/lifecycle.py +69 -0
- harness/orchestrator/native_resume.py +93 -0
- harness/orchestrator/policies.py +101 -0
- harness/orchestrator/reconcile.py +39 -0
- harness/orchestrator/run_graph.py +62 -0
- harness/orchestrator/snapshot.py +49 -0
- harness/schemas/__init__.py +1 -0
- harness/schemas/adapter.py +26 -0
- harness/schemas/approval.py +25 -0
- harness/schemas/artifact.py +17 -0
- harness/schemas/common.py +20 -0
- harness/schemas/memory.py +50 -0
- harness/schemas/policy.py +39 -0
- harness/schemas/run.py +116 -0
- harness/schemas/session.py +93 -0
- harness/schemas/task.py +24 -0
- harness/security/__init__.py +5 -0
- harness/security/approval_policy.py +107 -0
- harness/security/auth.py +106 -0
- harness/security/permissions.py +59 -0
- harness/security/risk.py +59 -0
- harness/security/secrets.py +49 -0
- harness/services/__init__.py +1 -0
- harness/services/adapters_health.py +212 -0
- harness/services/approvals.py +84 -0
- harness/services/artifacts.py +78 -0
- harness/services/attachments.py +228 -0
- harness/services/comparison.py +345 -0
- harness/services/doctor.py +156 -0
- harness/services/files.py +287 -0
- harness/services/mcp_servers.py +179 -0
- harness/services/project_git.py +101 -0
- harness/services/retention.py +97 -0
- harness/services/runs.py +155 -0
- harness/services/runs_diff.py +201 -0
- harness/services/sessions.py +242 -0
- harness/services/ssh.py +300 -0
- harness/services/stats.py +132 -0
- harness/services/tasks.py +41 -0
- harness/services/workspaces.py +184 -0
- harness/services/worktrees.py +439 -0
- harness/settings.py +186 -0
- harness/skills/__init__.py +11 -0
- harness/skills/manager.py +141 -0
- harness/tools/__init__.py +1 -0
- harness/tools/fs_tools.py +295 -0
- harness/workers/__init__.py +0 -0
- harness/workers/dispatch.py +26 -0
- 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()
|
cli/commands/service.py
ADDED
|
@@ -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
|
+
)
|