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/diff.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""``ags diff [run_id]`` — show what an agent changed.
|
|
2
|
+
|
|
3
|
+
Without a run ID: fetches the newest run (in the current workspace) that has a
|
|
4
|
+
pre-run git snapshot and renders its diff. With a run ID: fetches that specific
|
|
5
|
+
run's diff.
|
|
6
|
+
|
|
7
|
+
Output is rendered via Rich's :class:`~rich.syntax.Syntax` with the ``diff``
|
|
8
|
+
lexer so added lines are green and removed lines are red in terminals that
|
|
9
|
+
support color.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from rich.syntax import Syntax
|
|
16
|
+
|
|
17
|
+
from cli.client import HarnessClient
|
|
18
|
+
|
|
19
|
+
console = Console()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def diff(
|
|
23
|
+
run_id: str = typer.Argument(
|
|
24
|
+
None,
|
|
25
|
+
help="Run ID to diff. Omit to use the newest run with a snapshot in this workspace.",
|
|
26
|
+
),
|
|
27
|
+
workspace: str = typer.Option("default", "--workspace", "-w", envvar="HARNESS_WORKSPACE"),
|
|
28
|
+
) -> None:
|
|
29
|
+
"""Show the git diff produced by an agent run (requires a pre-run snapshot)."""
|
|
30
|
+
client = HarnessClient()
|
|
31
|
+
|
|
32
|
+
if run_id:
|
|
33
|
+
data = client.get(f"/runs/{run_id}/diff")
|
|
34
|
+
else:
|
|
35
|
+
data = client.get("/runs/diff/latest", workspace=workspace)
|
|
36
|
+
|
|
37
|
+
sha = data.get("snapshot", "")
|
|
38
|
+
diff_text: str = data.get("diff", "")
|
|
39
|
+
truncated: bool = data.get("truncated", False)
|
|
40
|
+
|
|
41
|
+
console.print(f"[dim]snapshot:[/dim] [cyan]{sha}[/cyan]")
|
|
42
|
+
if truncated:
|
|
43
|
+
console.print("[yellow]Warning: diff truncated at 1 MB[/yellow]")
|
|
44
|
+
|
|
45
|
+
if not diff_text.strip():
|
|
46
|
+
console.print("[dim]No changes detected.[/dim]")
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
console.print(Syntax(diff_text, "diff", theme="monokai", line_numbers=False))
|
cli/commands/doctor.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""``ags doctor`` — end-to-end provider preflight check.
|
|
2
|
+
|
|
3
|
+
Calls GET /doctor on the running API and renders a Rich table showing, for
|
|
4
|
+
each enabled provider: binary, version, health, latency, model, MCP flag
|
|
5
|
+
support, native-session eligibility, and any detected problems.
|
|
6
|
+
|
|
7
|
+
Exit code 1 when any listed provider has problems.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
|
|
16
|
+
from cli.client import APIError, HarnessClient
|
|
17
|
+
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def doctor(
|
|
22
|
+
provider: str | None = typer.Argument(
|
|
23
|
+
None, help="Filter to a single provider name. Omit to check all enabled providers."
|
|
24
|
+
),
|
|
25
|
+
) -> None:
|
|
26
|
+
"""Run preflight health checks on all (or one) enabled provider(s)."""
|
|
27
|
+
client = HarnessClient()
|
|
28
|
+
try:
|
|
29
|
+
rows: list[dict] = client.get("/doctor")
|
|
30
|
+
except APIError as exc:
|
|
31
|
+
console.print(f"[red]✗ {exc}[/red]")
|
|
32
|
+
raise typer.Exit(1) from exc
|
|
33
|
+
|
|
34
|
+
# Optional single-provider filter
|
|
35
|
+
if provider:
|
|
36
|
+
rows = [r for r in rows if r["provider"] == provider]
|
|
37
|
+
if not rows:
|
|
38
|
+
console.print(f"[yellow]No enabled provider named {provider!r}[/yellow]")
|
|
39
|
+
raise typer.Exit(1)
|
|
40
|
+
|
|
41
|
+
if not rows:
|
|
42
|
+
console.print("[dim]No enabled providers found.[/dim]")
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
table = Table(
|
|
46
|
+
title="Provider preflight",
|
|
47
|
+
title_justify="left",
|
|
48
|
+
show_lines=False,
|
|
49
|
+
)
|
|
50
|
+
table.add_column("provider", style="bold")
|
|
51
|
+
table.add_column("kind")
|
|
52
|
+
table.add_column("version")
|
|
53
|
+
table.add_column("health")
|
|
54
|
+
table.add_column("latency (ms)")
|
|
55
|
+
table.add_column("model")
|
|
56
|
+
table.add_column("MCP")
|
|
57
|
+
table.add_column("native")
|
|
58
|
+
table.add_column("problems")
|
|
59
|
+
|
|
60
|
+
has_problems = False
|
|
61
|
+
for r in rows:
|
|
62
|
+
problems = r.get("problems") or []
|
|
63
|
+
if problems:
|
|
64
|
+
has_problems = True
|
|
65
|
+
|
|
66
|
+
health_cell = "[green]ok[/green]" if r["health_ok"] else "[red]FAIL[/red]"
|
|
67
|
+
latency_cell = f"{r['latency_ms']:.0f}" if r.get("latency_ms") else "-"
|
|
68
|
+
mcp_val = r.get("mcp_ok")
|
|
69
|
+
mcp_cell = (
|
|
70
|
+
"-" if mcp_val is None
|
|
71
|
+
else ("[green]yes[/green]" if mcp_val else "[yellow]no[/yellow]")
|
|
72
|
+
)
|
|
73
|
+
native_cell = "[green]yes[/green]" if r.get("native_sessions") else "no"
|
|
74
|
+
problems_cell = (
|
|
75
|
+
"[red]" + "; ".join(problems) + "[/red]" if problems else "[dim]none[/dim]"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
table.add_row(
|
|
79
|
+
r["provider"],
|
|
80
|
+
r.get("kind") or "-",
|
|
81
|
+
r.get("version") or "-",
|
|
82
|
+
health_cell,
|
|
83
|
+
latency_cell,
|
|
84
|
+
r.get("model") or "-",
|
|
85
|
+
mcp_cell,
|
|
86
|
+
native_cell,
|
|
87
|
+
problems_cell,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
console.print(table)
|
|
91
|
+
|
|
92
|
+
if has_problems:
|
|
93
|
+
raise typer.Exit(1)
|
cli/commands/gui.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""``ags gui`` — open the AgentS web dashboard.
|
|
2
|
+
|
|
3
|
+
The web GUI is served by the local server at ``/ui`` (same origin as the API), so this
|
|
4
|
+
just makes sure the server is up and opens the browser there."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import webbrowser
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
import typer
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
|
|
14
|
+
from cli.local import ensure_local_server
|
|
15
|
+
|
|
16
|
+
console = Console()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def gui(
|
|
20
|
+
no_open: bool = typer.Option(
|
|
21
|
+
False, "--no-open", help="Print the URL but don't open a browser."
|
|
22
|
+
),
|
|
23
|
+
) -> None:
|
|
24
|
+
"""Open the web GUI in your browser (starts the local server if needed)."""
|
|
25
|
+
base = ensure_local_server()
|
|
26
|
+
if not base:
|
|
27
|
+
console.print("[red]✗ couldn't reach or start the local server.[/red] Try `ags serve`.")
|
|
28
|
+
raise typer.Exit(1)
|
|
29
|
+
|
|
30
|
+
ui_url = f"{base}/ui/"
|
|
31
|
+
try:
|
|
32
|
+
built = httpx.get(ui_url, timeout=3.0).status_code == 200
|
|
33
|
+
except httpx.HTTPError:
|
|
34
|
+
built = False
|
|
35
|
+
if not built:
|
|
36
|
+
console.print(
|
|
37
|
+
"[yellow]The web GUI isn't built yet.[/yellow] Build it once with:\n"
|
|
38
|
+
" [bold]cd webgui && npm install && npm run build[/bold]\n"
|
|
39
|
+
"then re-run [bold]ags gui[/bold]."
|
|
40
|
+
)
|
|
41
|
+
raise typer.Exit(1)
|
|
42
|
+
|
|
43
|
+
console.print(f"[green]✓[/green] AgentS GUI: [bold]{ui_url}[/bold]")
|
|
44
|
+
if not no_open:
|
|
45
|
+
webbrowser.open(ui_url)
|
cli/commands/init.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""``ags init`` — prepare the daemonless local instance (SQLite + seed)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
console = Console()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def init() -> None:
|
|
13
|
+
"""Create ~/.ags (config + SQLite schema + seeded providers). Idempotent."""
|
|
14
|
+
from harness.bootstrap import init_local
|
|
15
|
+
from harness.settings import HOME_DIR, get_settings
|
|
16
|
+
|
|
17
|
+
asyncio.run(init_local())
|
|
18
|
+
console.print(f"✓ AgentS initialized at [bold]{HOME_DIR}[/bold]")
|
|
19
|
+
console.print(f" database: {get_settings().database_url}")
|
|
20
|
+
console.print("\nNext: [bold]ags chat[/bold] (the server auto-starts on first use)")
|
cli/commands/mcp.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.table import Table
|
|
6
|
+
|
|
7
|
+
from cli.client import HarnessClient
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(help="List and test MCP servers (tools agents can call).")
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@app.command("list")
|
|
14
|
+
def list_servers() -> None:
|
|
15
|
+
"""List configured MCP servers (from ``mcp.servers`` in config.yaml)."""
|
|
16
|
+
client = HarnessClient()
|
|
17
|
+
rows = client.get("/mcp/servers")
|
|
18
|
+
if not rows:
|
|
19
|
+
console.print(
|
|
20
|
+
"No MCP servers configured. Add them under [bold]mcp.servers[/bold] in "
|
|
21
|
+
"config.yaml, then re-run [bold]ags init[/bold]."
|
|
22
|
+
)
|
|
23
|
+
return
|
|
24
|
+
table = Table("name", "transport", "enabled", "read_only", "allowlist", "bind")
|
|
25
|
+
for r in rows:
|
|
26
|
+
allow = ", ".join(r.get("tool_allowlist") or []) or "all"
|
|
27
|
+
bind = r.get("bind") or {}
|
|
28
|
+
bind_s = (
|
|
29
|
+
f"ws={bind.get('workspaces') or 'all'} "
|
|
30
|
+
f"prov={bind.get('providers') or 'all'}"
|
|
31
|
+
)
|
|
32
|
+
table.add_row(
|
|
33
|
+
r["name"], r["transport"], str(r["enabled"]),
|
|
34
|
+
str(r["read_only"]), allow, bind_s,
|
|
35
|
+
)
|
|
36
|
+
console.print(table)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@app.command("test")
|
|
40
|
+
def test(name: str = typer.Argument(..., help="MCP server name.")) -> None:
|
|
41
|
+
"""Connect to an MCP server and list the tools it advertises."""
|
|
42
|
+
client = HarnessClient()
|
|
43
|
+
data = client.post(f"/mcp/servers/{name}/test")
|
|
44
|
+
if data["ok"]:
|
|
45
|
+
tools = data.get("tools") or []
|
|
46
|
+
console.print(f"[green]{name}[/green] ok — {len(tools)} tool(s)")
|
|
47
|
+
for t in tools:
|
|
48
|
+
console.print(f" • mcp__{name}__{t}")
|
|
49
|
+
else:
|
|
50
|
+
console.print(f"[red]{name}[/red] failed: {data.get('error')}")
|
cli/commands/memory.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.table import Table
|
|
6
|
+
|
|
7
|
+
from cli.client import HarnessClient
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(help="Ingest and search shared memory.")
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@app.command("ingest")
|
|
14
|
+
def ingest(
|
|
15
|
+
content: str = typer.Argument(..., help="Fact/decision text to remember."),
|
|
16
|
+
workspace: str = typer.Option("default", "--workspace", "-w"),
|
|
17
|
+
mem_type: str = typer.Option("fact", "--type", help="fact|decision|preference|architecture"),
|
|
18
|
+
tags: str = typer.Option("", "--tags", help="Comma-separated tags."),
|
|
19
|
+
) -> None:
|
|
20
|
+
"""Ingest a memory item (secrets are redacted before storage)."""
|
|
21
|
+
client = HarnessClient()
|
|
22
|
+
data = client.post(
|
|
23
|
+
"/memory/ingest",
|
|
24
|
+
json={
|
|
25
|
+
"content": content, "workspace": workspace, "type": mem_type,
|
|
26
|
+
"tags": [t for t in tags.split(",") if t],
|
|
27
|
+
},
|
|
28
|
+
)
|
|
29
|
+
console.print(f"[green]stored[/green] {data['id']} redacted={data['redacted']}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@app.command("search")
|
|
33
|
+
def search(
|
|
34
|
+
query: str = typer.Argument(...),
|
|
35
|
+
workspace: str = typer.Option("default", "--workspace", "-w"),
|
|
36
|
+
top_k: int = typer.Option(8, "--top-k", "-k"),
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Search shared memory across providers."""
|
|
39
|
+
client = HarnessClient()
|
|
40
|
+
data = client.post(
|
|
41
|
+
"/memory/search", json={"query": query, "workspace": workspace, "top_k": top_k}
|
|
42
|
+
)
|
|
43
|
+
table = Table("score", "type", "content")
|
|
44
|
+
for hit in data["hits"]:
|
|
45
|
+
table.add_row(str(hit["score"]), hit["type"], hit["content"][:80])
|
|
46
|
+
console.print(table)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@app.command("reindex")
|
|
50
|
+
def reindex(
|
|
51
|
+
workspace: str | None = typer.Option(None, "--workspace", "-w",
|
|
52
|
+
help="Limit reindex to a specific workspace."),
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Re-embed stale memory items after switching embedding provider or model.
|
|
55
|
+
|
|
56
|
+
Deletes embeddings produced by any model other than the currently active
|
|
57
|
+
embedder and re-embeds those items in batches of 64. Items already using
|
|
58
|
+
the active model are untouched.
|
|
59
|
+
"""
|
|
60
|
+
client = HarnessClient()
|
|
61
|
+
payload: dict = {}
|
|
62
|
+
if workspace is not None:
|
|
63
|
+
payload["workspace"] = workspace
|
|
64
|
+
data = client.post("/memory/reindex", json=payload)
|
|
65
|
+
console.print(f"[green]reindexed[/green] {data['reindexed']} item(s)")
|
cli/commands/model.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""``ags models list`` / ``ags model set`` — list and select a provider's model.
|
|
2
|
+
|
|
3
|
+
ags models list # models for every enabled provider
|
|
4
|
+
ags models list agy # just agy's catalogue (marks the active one)
|
|
5
|
+
ags model set agy "Gemini 3.1 Pro (High)" # select agy's model
|
|
6
|
+
|
|
7
|
+
The convenience forms ``ags agy models list`` and ``ags agy model set <name>``
|
|
8
|
+
are rewritten to these by the top-level dispatcher (see ``cli.main.route``).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
|
|
17
|
+
from cli.client import HarnessClient
|
|
18
|
+
from cli.commands.ask import _canon
|
|
19
|
+
|
|
20
|
+
console = Console()
|
|
21
|
+
|
|
22
|
+
models_app = typer.Typer(help="List provider models.", no_args_is_help=False)
|
|
23
|
+
model_app = typer.Typer(help="Select a provider's model.", no_args_is_help=True)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@models_app.command("list")
|
|
27
|
+
def list_models(
|
|
28
|
+
provider: str | None = typer.Argument(
|
|
29
|
+
None, help="Provider to list (claude | agy | local | …). Omit for all."
|
|
30
|
+
),
|
|
31
|
+
) -> None:
|
|
32
|
+
"""List available models for a provider (or all providers)."""
|
|
33
|
+
client = HarnessClient()
|
|
34
|
+
if provider:
|
|
35
|
+
names = [_canon(provider)]
|
|
36
|
+
else:
|
|
37
|
+
names = sorted({a["name"] for a in client.get("/adapters") if a.get("enabled", True)})
|
|
38
|
+
|
|
39
|
+
for name in names:
|
|
40
|
+
try:
|
|
41
|
+
data = client.get(f"/adapters/{name}/models")
|
|
42
|
+
except Exception as exc: # noqa: BLE001 — surface per-provider, keep going
|
|
43
|
+
console.print(f"[yellow]{name}: {exc}[/yellow]")
|
|
44
|
+
continue
|
|
45
|
+
table = Table(title=f"{data['provider']} ({data['kind']})", title_justify="left")
|
|
46
|
+
table.add_column("", width=2)
|
|
47
|
+
table.add_column("model")
|
|
48
|
+
current = data.get("current")
|
|
49
|
+
for m in data["models"]:
|
|
50
|
+
mark = "[green]●[/green]" if m == current else " "
|
|
51
|
+
table.add_row(mark, f"[bold]{m}[/bold]" if m == current else m)
|
|
52
|
+
if not data["models"]:
|
|
53
|
+
table.add_row(" ", "[dim](provider exposes no model list)[/dim]")
|
|
54
|
+
console.print(table)
|
|
55
|
+
console.print(f"[dim]active: {current or '(none)'} — "
|
|
56
|
+
f"set with `ags model set {data['provider']} \"<name>\"`[/dim]\n")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@model_app.command("set")
|
|
60
|
+
def set_model(
|
|
61
|
+
provider: str = typer.Argument(..., help="Provider to configure: claude | agy | local | …."),
|
|
62
|
+
name: str = typer.Argument(..., help="Model to select (quote names with spaces)."),
|
|
63
|
+
) -> None:
|
|
64
|
+
"""Select the active model for a provider (persists; used by future runs)."""
|
|
65
|
+
client = HarnessClient()
|
|
66
|
+
provider = _canon(provider)
|
|
67
|
+
try:
|
|
68
|
+
data = client.post(f"/adapters/{provider}/model", json={"model": name})
|
|
69
|
+
except Exception as exc: # noqa: BLE001
|
|
70
|
+
console.print(f"[red]{exc}[/red]")
|
|
71
|
+
raise typer.Exit(1) from exc
|
|
72
|
+
console.print(f"✓ [bold]{data['provider']}[/bold] now uses model "
|
|
73
|
+
f"[bold]{data['current']}[/bold]")
|
cli/commands/policy.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
from cli.client import HarnessClient
|
|
9
|
+
|
|
10
|
+
app = typer.Typer(help="Manage policies and approvals.")
|
|
11
|
+
console = Console()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@app.command("set")
|
|
15
|
+
def set_policy(
|
|
16
|
+
name: str = typer.Argument(...),
|
|
17
|
+
kind: str = typer.Option(..., "--kind", help="routing|approval|egress|fs_allowlist"),
|
|
18
|
+
spec: str = typer.Option("{}", "--spec", help="JSON spec."),
|
|
19
|
+
workspace: str = typer.Option("default", "--workspace", "-w"),
|
|
20
|
+
priority: int = typer.Option(0, "--priority"),
|
|
21
|
+
) -> None:
|
|
22
|
+
"""Create or update a policy."""
|
|
23
|
+
client = HarnessClient()
|
|
24
|
+
data = client.put(
|
|
25
|
+
"/policies",
|
|
26
|
+
json={
|
|
27
|
+
"name": name, "kind": kind, "spec": json.loads(spec),
|
|
28
|
+
"workspace": workspace, "priority": priority,
|
|
29
|
+
},
|
|
30
|
+
)
|
|
31
|
+
console.print(f"[green]policy set[/green] {data['id']} ({data['kind']})")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.command("approvals")
|
|
35
|
+
def approvals(status: str = typer.Option(None, "--status")) -> None:
|
|
36
|
+
"""List pending approvals."""
|
|
37
|
+
client = HarnessClient()
|
|
38
|
+
rows = client.get("/approvals", **({"status": status} if status else {}))
|
|
39
|
+
console.print_json(data=rows)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@app.command("decide")
|
|
43
|
+
def decide(
|
|
44
|
+
approval_id: str = typer.Argument(...),
|
|
45
|
+
status: str = typer.Option(..., "--status", help="approved|denied"),
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Approve or deny a pending approval."""
|
|
48
|
+
client = HarnessClient()
|
|
49
|
+
data = client.post(
|
|
50
|
+
f"/approvals/{approval_id}/decide", json={"decision": status}
|
|
51
|
+
)
|
|
52
|
+
console.print(f"[green]decided[/green] {data['id']} -> {data['status']}")
|
cli/commands/project.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""``ags project`` — show or change the project folder the agents work in, and
|
|
2
|
+
the plan/write mode. Both are per-workspace runtime settings (stored server-side),
|
|
3
|
+
so a change takes effect on the next message — no restart.
|
|
4
|
+
|
|
5
|
+
ags project # show active folder + mode
|
|
6
|
+
ags project set ~/code/myrepo # point agents at a folder
|
|
7
|
+
ags project set /srv/repo --host box # remote folder on ssh host "box"
|
|
8
|
+
ags project host box # move the project to an ssh host
|
|
9
|
+
ags project host --clear # back to local
|
|
10
|
+
ags project mode read-only # or: write
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import typer
|
|
18
|
+
from rich.console import Console
|
|
19
|
+
|
|
20
|
+
from cli.client import APIError, HarnessClient
|
|
21
|
+
|
|
22
|
+
console = Console()
|
|
23
|
+
|
|
24
|
+
app = typer.Typer(help="Show or change the active project folder and mode.",
|
|
25
|
+
no_args_is_help=False)
|
|
26
|
+
|
|
27
|
+
_READONLY = {"read-only", "readonly", "ro", "r", "plan"}
|
|
28
|
+
_WRITE = {"write", "rw", "w"}
|
|
29
|
+
_WS = typer.Option("default", "--workspace", "-w", envvar="HARNESS_WORKSPACE")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@app.callback(invoke_without_command=True)
|
|
33
|
+
def _default(ctx: typer.Context, workspace: str = _WS) -> None:
|
|
34
|
+
"""With no subcommand, show the active project folder and mode."""
|
|
35
|
+
if ctx.invoked_subcommand is not None:
|
|
36
|
+
return
|
|
37
|
+
client = HarnessClient()
|
|
38
|
+
try:
|
|
39
|
+
data = client.get("/project", workspace=workspace)
|
|
40
|
+
except APIError as exc:
|
|
41
|
+
console.print(f"[red]✗ {exc}[/red]")
|
|
42
|
+
raise typer.Exit(1) from exc
|
|
43
|
+
console.print(f"[bold]workspace[/bold] {workspace}")
|
|
44
|
+
console.print(f"[bold]project[/bold] {data.get('project_dir', '?')}")
|
|
45
|
+
if data.get("project_host"):
|
|
46
|
+
console.print(f"[bold]host[/bold] {data['project_host']} (remote via ssh)")
|
|
47
|
+
console.print(f"[bold]mode[/bold] {data.get('mode', '?')}")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@app.command("set")
|
|
51
|
+
def set_project(
|
|
52
|
+
path: str = typer.Argument(..., help="Project folder the agents should operate in."),
|
|
53
|
+
host: str | None = typer.Option(
|
|
54
|
+
None, "--host", help="ssh alias/hostname the folder lives on (validated remotely)."),
|
|
55
|
+
workspace: str = _WS,
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Point the agents at a project folder (validated to exist; persists).
|
|
58
|
+
With --host the folder is on that ssh host and is validated over ssh."""
|
|
59
|
+
body: dict = {}
|
|
60
|
+
if host is not None:
|
|
61
|
+
body["project_host"] = host
|
|
62
|
+
body["project_dir"] = path # remote POSIX path — server validates over ssh
|
|
63
|
+
else:
|
|
64
|
+
p = Path(path).expanduser().resolve()
|
|
65
|
+
if not p.is_dir():
|
|
66
|
+
console.print(f"[red]✗ not a directory: {p}[/red]")
|
|
67
|
+
raise typer.Exit(1)
|
|
68
|
+
body["project_dir"] = str(p)
|
|
69
|
+
client = HarnessClient()
|
|
70
|
+
try:
|
|
71
|
+
data = client.put("/project", json=body, workspace=workspace)
|
|
72
|
+
except APIError as exc:
|
|
73
|
+
console.print(f"[red]✗ {exc}[/red]")
|
|
74
|
+
raise typer.Exit(1) from exc
|
|
75
|
+
where = f" on {data['project_host']}" if data.get("project_host") else ""
|
|
76
|
+
console.print(f"✓ [bold]{workspace}[/bold] project → {data.get('project_dir')}{where}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@app.command("host")
|
|
80
|
+
def set_host(
|
|
81
|
+
host: str = typer.Argument("", help="ssh alias/hostname (empty with --clear)."),
|
|
82
|
+
clear: bool = typer.Option(False, "--clear", help="Back to a local project."),
|
|
83
|
+
workspace: str = _WS,
|
|
84
|
+
) -> None:
|
|
85
|
+
"""Move the project to a remote ssh host (or back to local with --clear)."""
|
|
86
|
+
if not clear and not host.strip():
|
|
87
|
+
console.print("[red]✗ give an ssh host, or --clear for local[/red]")
|
|
88
|
+
raise typer.Exit(1)
|
|
89
|
+
client = HarnessClient()
|
|
90
|
+
try:
|
|
91
|
+
data = client.put("/project", json={"project_host": "" if clear else host.strip()},
|
|
92
|
+
workspace=workspace)
|
|
93
|
+
except APIError as exc:
|
|
94
|
+
console.print(f"[red]✗ {exc}[/red]")
|
|
95
|
+
raise typer.Exit(1) from exc
|
|
96
|
+
if data.get("project_host"):
|
|
97
|
+
console.print(f"✓ [bold]{workspace}[/bold] host → {data['project_host']}")
|
|
98
|
+
else:
|
|
99
|
+
console.print(f"✓ [bold]{workspace}[/bold] project is local")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@app.command("mode")
|
|
103
|
+
def mode(
|
|
104
|
+
value: str = typer.Argument(..., help="read-only (plan) | write (agents can edit)."),
|
|
105
|
+
workspace: str = _WS,
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Switch between read-only (plan) and write mode (persists; next run picks it up)."""
|
|
108
|
+
v = value.strip().lower()
|
|
109
|
+
if v in _READONLY:
|
|
110
|
+
write = False
|
|
111
|
+
elif v in _WRITE:
|
|
112
|
+
write = True
|
|
113
|
+
else:
|
|
114
|
+
console.print("[red]✗ mode must be 'read-only' or 'write'[/red]")
|
|
115
|
+
raise typer.Exit(1)
|
|
116
|
+
client = HarnessClient()
|
|
117
|
+
try:
|
|
118
|
+
data = client.put("/project", json={"write_mode": write}, workspace=workspace)
|
|
119
|
+
except APIError as exc:
|
|
120
|
+
console.print(f"[red]✗ {exc}[/red]")
|
|
121
|
+
raise typer.Exit(1) from exc
|
|
122
|
+
console.print(f"✓ [bold]{workspace}[/bold] mode → {data.get('mode')}")
|
cli/commands/quota.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""``ags quota`` — usage + plan-limit status for the active (or named) provider.
|
|
2
|
+
|
|
3
|
+
Two things are shown:
|
|
4
|
+
|
|
5
|
+
1. **AgentS-tracked usage** — runs / tokens / cost AgentS has recorded for the
|
|
6
|
+
provider (from the run ledger). Always available, fully scriptable.
|
|
7
|
+
2. **Provider plan quota** — the provider's own 5-hour / weekly limits. Neither the
|
|
8
|
+
`claude` nor `agy` CLI exposes these in a scriptable command (they live in the
|
|
9
|
+
interactive `/usage` and `/credits` views). Where a machine-readable path exists
|
|
10
|
+
(`antigravity-usage --json` for Antigravity) we use it; otherwise we point you to it.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
|
|
19
|
+
import typer
|
|
20
|
+
from rich.console import Console
|
|
21
|
+
from rich.table import Table
|
|
22
|
+
|
|
23
|
+
from cli.client import HarnessClient
|
|
24
|
+
from cli.commands.ask import _canon, _resolve_provider
|
|
25
|
+
|
|
26
|
+
console = Console()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def quota(
|
|
30
|
+
provider: str | None = typer.Argument(
|
|
31
|
+
None, help="Provider to check: claude | agy | local | mock. Omit to use the active one."
|
|
32
|
+
),
|
|
33
|
+
workspace: str = typer.Option("default", "--workspace", "-w", envvar="HARNESS_WORKSPACE"),
|
|
34
|
+
json_out: bool = typer.Option(False, "--json", help="Emit AgentS usage as JSON and exit."),
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Show usage + plan-limit status for the active (or named) provider.
|
|
37
|
+
|
|
38
|
+
For the all-providers ledger totals (no plan limits), use: ags usage
|
|
39
|
+
"""
|
|
40
|
+
prov = _canon(provider) if provider else _resolve_provider(None, workspace)
|
|
41
|
+
client = HarnessClient()
|
|
42
|
+
|
|
43
|
+
rows = client.get("/usage", workspace=workspace)
|
|
44
|
+
mine = next((r for r in rows if r["provider"] == prov), None)
|
|
45
|
+
|
|
46
|
+
if json_out:
|
|
47
|
+
console.print_json(data=mine or {"provider": prov, "runs": 0})
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
# 1) AgentS-tracked usage (always available).
|
|
51
|
+
table = Table(title=f"AgentS usage — {prov} (workspace: {workspace})", title_justify="left")
|
|
52
|
+
for col in ("runs", "prompt tok", "completion tok", "total tok", "cost (USD)"):
|
|
53
|
+
table.add_column(col)
|
|
54
|
+
if mine:
|
|
55
|
+
table.add_row(
|
|
56
|
+
str(mine["runs"]), f"{mine['prompt_tokens']:,}", f"{mine['completion_tokens']:,}",
|
|
57
|
+
f"{mine['total_tokens']:,}", f"${mine['usd']:.4f}",
|
|
58
|
+
)
|
|
59
|
+
else:
|
|
60
|
+
table.add_row("0", "0", "0", "0", "$0.0000")
|
|
61
|
+
console.print(table)
|
|
62
|
+
console.print(
|
|
63
|
+
"[dim]AgentS's own accounting from the run ledger — "
|
|
64
|
+
"not the provider's plan limit.[/dim]\n"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# 2) Provider plan quota (best available per provider).
|
|
68
|
+
if prov == "antigravity":
|
|
69
|
+
_antigravity_plan_quota()
|
|
70
|
+
elif prov == "claude":
|
|
71
|
+
console.print("[bold]Plan limits (claude):[/bold]")
|
|
72
|
+
console.print(
|
|
73
|
+
" The `claude` CLI exposes 5-hour / weekly plan limits only via the "
|
|
74
|
+
"interactive [bold]/usage[/bold] command — there is no headless command.\n"
|
|
75
|
+
" Check it with: [bold]claude[/bold] then type [bold]/usage[/bold] "
|
|
76
|
+
"(or open https://platform.claude.com/usage)."
|
|
77
|
+
)
|
|
78
|
+
elif prov in ("local", "mock"):
|
|
79
|
+
console.print(f"[dim]No plan quota for '{prov}' (self-hosted / mock).[/dim]")
|
|
80
|
+
else:
|
|
81
|
+
console.print(f"[dim]No known plan-quota source for '{prov}'.[/dim]")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _antigravity_plan_quota() -> None:
|
|
85
|
+
"""Antigravity plan quota via the third-party `antigravity-usage` tool, if present."""
|
|
86
|
+
console.print("[bold]Plan limits (antigravity / AI Credits):[/bold]")
|
|
87
|
+
tool = shutil.which("antigravity-usage")
|
|
88
|
+
if not tool:
|
|
89
|
+
console.print(
|
|
90
|
+
" No headless command in `agy` itself. Options:\n"
|
|
91
|
+
" • interactive: [bold]agy[/bold] then type [bold]/credits[/bold]\n"
|
|
92
|
+
" • scriptable: install [bold]antigravity-usage[/bold] "
|
|
93
|
+
"([dim]npm i -g antigravity-usage[/dim]) and re-run `ags quota agy`."
|
|
94
|
+
)
|
|
95
|
+
return
|
|
96
|
+
try:
|
|
97
|
+
out = subprocess.run(
|
|
98
|
+
[tool, "--json"], capture_output=True, text=True, timeout=30, check=False
|
|
99
|
+
)
|
|
100
|
+
except (subprocess.SubprocessError, OSError) as exc: # noqa: BLE001
|
|
101
|
+
console.print(f" [yellow]antigravity-usage failed: {exc}[/yellow]")
|
|
102
|
+
return
|
|
103
|
+
raw = (out.stdout or out.stderr).strip()
|
|
104
|
+
try:
|
|
105
|
+
console.print_json(data=json.loads(raw))
|
|
106
|
+
except json.JSONDecodeError:
|
|
107
|
+
console.print(raw or " [yellow](no output; are you signed in to agy?)[/yellow]")
|