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/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']}")
@@ -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]")