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
@@ -0,0 +1,219 @@
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
+ from cli.commands.ask import _state_dir
9
+
10
+ app = typer.Typer(help="Inspect, resume, branch, and clear sessions.")
11
+ console = Console()
12
+
13
+
14
+ @app.command("list")
15
+ def list_sessions(limit: int = typer.Option(20, "--limit")) -> None:
16
+ client = HarnessClient()
17
+ rows = client.get("/sessions", limit=limit)
18
+ table = Table("id", "status", "objective")
19
+ for r in rows:
20
+ table.add_row(r["id"], r["status"], r["canonical_objective"][:60])
21
+ console.print(table)
22
+
23
+
24
+ @app.command("show")
25
+ def show(session_id: str = typer.Argument(...)) -> None:
26
+ client = HarnessClient()
27
+ data = client.get(f"/sessions/{session_id}")
28
+ console.print_json(data=data)
29
+ runs = client.get(f"/sessions/{session_id}/runs")
30
+ table = Table("run_id", "provider", "role", "status")
31
+ for r in runs:
32
+ table.add_row(r["id"], r["adapter_kind"], r["role"], r["status"])
33
+ console.print(table)
34
+
35
+
36
+ @app.command("resume")
37
+ def resume(
38
+ session_id: str = typer.Argument(...),
39
+ policy: str = typer.Option("single", "--policy", "-p"),
40
+ providers: str = typer.Option(None, "--providers"),
41
+ inline: bool = typer.Option(False, "--inline"),
42
+ ) -> None:
43
+ """Continue a session from its canonical summary."""
44
+ client = HarnessClient()
45
+ data = client.post(
46
+ f"/sessions/{session_id}/resume",
47
+ json={"policy": policy, "providers": providers.split(",") if providers else None},
48
+ inline=inline,
49
+ )
50
+ console.print(f"[green]resumed[/green] session {data['session_id']}")
51
+
52
+
53
+ @app.command("branch")
54
+ def branch(session_id: str = typer.Argument(...)) -> None:
55
+ """Branch a new session from canonical state."""
56
+ client = HarnessClient()
57
+ data = client.post(f"/sessions/{session_id}/branch", json={})
58
+ console.print(f"[green]branched[/green] -> {data['id']}")
59
+
60
+
61
+ @app.command("workspace")
62
+ def workspace(session_id: str = typer.Argument(...)) -> None:
63
+ """Show a session's private-workspace (worktree) state."""
64
+ client = HarnessClient()
65
+ st = client.get(f"/sessions/{session_id}/workspace") or {}
66
+ if st.get("workspace_mode") != "worktree":
67
+ console.print("[dim]session works directly in the main project folder[/dim]")
68
+ if st.get("fallback_reason"):
69
+ console.print(f"[yellow]({st['fallback_reason']})[/yellow]")
70
+ return
71
+ n = len(st.get("changed_files") or [])
72
+ console.print(f"[dim]branch[/dim] {st.get('branch', '?')} (base: {st.get('base', '?')})")
73
+ console.print(f"[dim]path[/dim] {st.get('path', '?')}")
74
+ console.print(
75
+ f"[dim]state[/dim] {n} changed file{'s' if n != 1 else ''}, "
76
+ f"{st.get('ahead', 0)} commit(s) ahead"
77
+ + (", dirty" if st.get("dirty") else "")
78
+ )
79
+ for f in st.get("changed_files") or []:
80
+ console.print(f" · {f}")
81
+ if st.get("merged_at"):
82
+ console.print(f"[green]merged at {st['merged_at']}[/green]")
83
+
84
+
85
+ @app.command("merge")
86
+ def merge(
87
+ session_id: str = typer.Argument(...),
88
+ squash: bool = typer.Option(False, "--squash", help="Squash-merge to one commit."),
89
+ delete_worktree: bool = typer.Option(
90
+ False, "--delete-worktree", help="Remove the worktree + branch after merging."
91
+ ),
92
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."),
93
+ ) -> None:
94
+ """Merge a session's private branch back into its base branch."""
95
+ client = HarnessClient()
96
+ if not yes:
97
+ st = client.get(f"/sessions/{session_id}/workspace") or {}
98
+ n = len(st.get("changed_files") or [])
99
+ console.print(
100
+ f"About to merge branch [bold]{st.get('branch', '?')}[/bold] "
101
+ f"({n} changed file(s)) into [bold]{st.get('base', '?')}[/bold]."
102
+ )
103
+ if not typer.confirm("Proceed?"):
104
+ raise typer.Exit(0)
105
+ out = client.post(
106
+ f"/sessions/{session_id}/merge",
107
+ json={"squash": squash, "delete_worktree": delete_worktree},
108
+ ) or {}
109
+ if out.get("merged"):
110
+ console.print(f"[green]✓ merged (commit {str(out.get('commit', ''))[:12]})[/green]")
111
+ elif out.get("nothing_to_merge"):
112
+ console.print("[cyan]nothing to merge — no new changes[/cyan]")
113
+ elif out.get("conflicts"):
114
+ console.print("[bold red]✗ merge conflicts (aborted):[/bold red]")
115
+ for f in out["conflicts"]:
116
+ console.print(f" · {f}")
117
+ else:
118
+ console.print(f"[yellow]merge did not complete: {out.get('reason') or out}[/yellow]")
119
+
120
+
121
+ @app.command("prune-worktrees")
122
+ def prune_worktrees() -> None:
123
+ """Remove worktree directories for sessions that no longer exist."""
124
+ client = HarnessClient()
125
+ out = client.post("/sessions/prune-worktrees", json={}) or {}
126
+ console.print(f"[green]removed {out.get('removed', 0)} orphan worktree(s)[/green]")
127
+
128
+
129
+ @app.command("compact")
130
+ def compact(
131
+ older_than: int = typer.Option(..., "--older-than", min=1, help="Compact sessions older than N days."),
132
+ dry_run: bool = typer.Option(False, "--dry-run", help="Preview without deleting."),
133
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."),
134
+ ) -> None:
135
+ """Prune bulky token/tool_result events from old terminal sessions.
136
+
137
+ Keeps the essential audit trail (message, status, cost, adapter_meta,
138
+ tool_call, error, artifact). Shows a preview and asks for confirmation
139
+ before deleting. Use --dry-run to preview without committing."""
140
+ client = HarnessClient()
141
+ preview = client.post("/sessions/compact", json={"older_than_days": older_than, "dry_run": True})
142
+ sessions = preview["sessions"]
143
+ events = preview["events_deleted"]
144
+ if sessions == 0:
145
+ console.print("[green]Nothing to compact[/green] — no qualifying sessions found.")
146
+ return
147
+
148
+ console.print(
149
+ f"[bold]{sessions}[/bold] session(s) matched; "
150
+ f"[bold red]{events}[/bold red] event(s) will be deleted (token + tool_result)."
151
+ )
152
+ if dry_run:
153
+ console.print("[yellow]Dry run — no changes made.[/yellow]")
154
+ return
155
+
156
+ if not yes and not typer.confirm("Compact these sessions?"):
157
+ console.print("Aborted.")
158
+ return
159
+
160
+ result = client.post("/sessions/compact", json={"older_than_days": older_than, "dry_run": False})
161
+ console.print(
162
+ f"[green]Compacted {result['sessions']} session(s),[/green] "
163
+ f"deleted {result['events_deleted']} event(s)."
164
+ )
165
+
166
+
167
+ @app.command("clear")
168
+ def clear(
169
+ ids: list[str] = typer.Argument(None, help="Specific session ids to clear."),
170
+ all_: bool = typer.Option(False, "--all", help="Clear ALL sessions."),
171
+ status: str = typer.Option(None, "--status", help="Only this status (e.g. failed)."),
172
+ older_than: int = typer.Option(None, "--older-than", min=1, help="Only sessions older than N days."),
173
+ workspace: str = typer.Option(None, "--workspace", "-w", help="Scope to a workspace slug."),
174
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."),
175
+ ) -> None:
176
+ """Permanently delete unwanted sessions (and their runs/events/memory) from the DB.
177
+
178
+ Combine filters (AND): --all, --status, --older-than, --workspace, or session ids.
179
+ Shows a preview and asks for confirmation before deleting."""
180
+ if not (all_ or status or older_than or workspace or ids):
181
+ console.print(
182
+ "[yellow]Nothing selected.[/yellow] Pick what to clear: --all, "
183
+ "--status <s>, --older-than <days>, --workspace <slug>, or session ids."
184
+ )
185
+ raise typer.Exit(1)
186
+
187
+ client = HarnessClient()
188
+ body = {
189
+ "all": all_, "status": status, "older_than_days": older_than,
190
+ "workspace": workspace, "ids": list(ids) if ids else None,
191
+ }
192
+ matches = client.post("/sessions/clear", json={**body, "dry_run": True})["sessions"]
193
+ if not matches:
194
+ console.print("[green]Nothing to clear[/green] — no sessions match.")
195
+ return
196
+
197
+ table = Table("id", "status", "objective")
198
+ for s in matches:
199
+ table.add_row(s["id"], s["status"], (s.get("canonical_objective") or "")[:60])
200
+ console.print(table)
201
+ console.print(f"[bold red]{len(matches)}[/bold red] session(s) will be permanently deleted.")
202
+ if not yes and not typer.confirm("Delete these sessions?"):
203
+ console.print("Aborted.")
204
+ return
205
+
206
+ result = client.post("/sessions/clear", json={**body, "dry_run": False})
207
+ deleted_ids = {s["id"] for s in result["sessions"]}
208
+ # Drop local active-session pointers that referenced a now-deleted session, so the
209
+ # next `ags chat` starts fresh instead of trying to resume a gone session.
210
+ cleared = 0
211
+ for f in _state_dir().glob("*.session"):
212
+ try:
213
+ if f.read_text().strip() in deleted_ids:
214
+ f.unlink()
215
+ cleared += 1
216
+ except OSError:
217
+ pass
218
+ note = f" (cleared {cleared} local pointer(s))" if cleared else ""
219
+ console.print(f"[green]Deleted {result['count']} session(s).[/green]{note}")
cli/commands/stats.py ADDED
@@ -0,0 +1,96 @@
1
+ """``ags stats`` — per-turn latency and token breakdown.
2
+
3
+ Shows queue time, first-event latency, total time, and token usage for each
4
+ run in a session. Defaults to the current workspace's session.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from pathlib import Path
11
+
12
+ import typer
13
+ from rich.console import Console
14
+ from rich.table import Table
15
+
16
+ from cli.client import HarnessClient
17
+ from cli.commands.ask import _session_file
18
+
19
+ console = Console()
20
+
21
+
22
+ def stats(
23
+ session_id: str | None = typer.Argument(
24
+ None, help="Session ID to fetch stats for. Defaults to current workspace session."
25
+ ),
26
+ workspace: str = typer.Option("default", "--workspace", "-w", envvar="HARNESS_WORKSPACE"),
27
+ json_out: bool = typer.Option(False, "--json", help="Emit stats as JSON and exit."),
28
+ ) -> None:
29
+ """Show per-run latency and token breakdown for a session."""
30
+ # Resolve session_id: use argument, or fallback to workspace's current session
31
+ if session_id is None:
32
+ session_file = _session_file(workspace)
33
+ if not session_file.exists():
34
+ console.print(
35
+ f"[yellow]No session found for workspace '{workspace}'.\n"
36
+ f"Start one with: [bold]ags ask \"...\"[/bold][/yellow]"
37
+ )
38
+ raise typer.Exit(1)
39
+ session_id = session_file.read_text().strip()
40
+ if not session_id:
41
+ console.print(
42
+ f"[yellow]Session file is empty for workspace '{workspace}'.[/yellow]"
43
+ )
44
+ raise typer.Exit(1)
45
+
46
+ client = HarnessClient()
47
+ try:
48
+ stats_data = client.get(f"/sessions/{session_id}/stats")
49
+ except Exception as exc:
50
+ console.print(f"[red]Error fetching stats: {exc}[/red]")
51
+ raise typer.Exit(1)
52
+
53
+ if json_out:
54
+ console.print_json(data=stats_data)
55
+ return
56
+
57
+ # Build a Rich table
58
+ table = Table(title=f"Run latencies and tokens (session {session_id[:8]})", title_justify="left")
59
+ table.add_column("run id", style="dim")
60
+ table.add_column("provider")
61
+ table.add_column("queue (ms)", justify="right")
62
+ table.add_column("first event (ms)", justify="right")
63
+ table.add_column("total (ms)", justify="right")
64
+ table.add_column("prompt tokens", justify="right")
65
+ table.add_column("completion tokens", justify="right")
66
+ table.add_column("resume")
67
+
68
+ # Track totals
69
+ total_prompt = 0
70
+ total_completion = 0
71
+
72
+ for run in stats_data:
73
+ run_id = run["run_id"][:8]
74
+ provider = run["provider"]
75
+ queue_ms = str(run["queue_ms"]) if run["queue_ms"] is not None else "—"
76
+ first_event_ms = str(run["first_event_ms"]) if run["first_event_ms"] is not None else "—"
77
+ total_ms = str(run["total_ms"]) if run["total_ms"] is not None else "—"
78
+ prompt_tokens = run["tokens"]["prompt"]
79
+ completion_tokens = run["tokens"]["completion"]
80
+ resume = run["resume"] or "—"
81
+
82
+ table.add_row(
83
+ run_id, provider, queue_ms, first_event_ms, total_ms,
84
+ str(prompt_tokens), str(completion_tokens), resume,
85
+ )
86
+
87
+ total_prompt += int(run["tokens"]["prompt"] or 0)
88
+ total_completion += int(run["tokens"]["completion"] or 0)
89
+
90
+ # Add totals row
91
+ table.add_row(
92
+ "[bold]totals[/bold]", "", "", "", "",
93
+ f"[bold]{total_prompt}[/bold]", f"[bold]{total_completion}[/bold]", "",
94
+ )
95
+
96
+ console.print(table)
cli/commands/status.py ADDED
@@ -0,0 +1,36 @@
1
+ """``ags status`` — what am I pointed at right now?
2
+
3
+ Answers the everyday questions: which project directory are the agents operating
4
+ in, are they in read-only (plan) or write mode, which workspace, and which agent is
5
+ the current default. Project dir and mode come from the running API (it is launched
6
+ rooted in the project, with ``HARNESS_WRITE`` controlling the mode)."""
7
+
8
+ from __future__ import annotations
9
+
10
+ import typer
11
+ from rich.console import Console
12
+
13
+ from cli.client import APIError, HarnessClient
14
+ from cli.commands.ask import _resolve_provider
15
+
16
+ console = Console()
17
+
18
+
19
+ def status(
20
+ workspace: str = typer.Option("default", "--workspace", "-w", envvar="HARNESS_WORKSPACE"),
21
+ ) -> None:
22
+ """Show the active project, mode (plan/write), workspace, and default agent."""
23
+ client = HarnessClient()
24
+ provider = _resolve_provider(None, workspace)
25
+ try:
26
+ st = client.get("/status", workspace=workspace)
27
+ except APIError as exc:
28
+ console.print(f"[red]✗ {exc}[/red]")
29
+ raise typer.Exit(1) from exc
30
+ console.print(f"[bold]workspace[/bold] {workspace}")
31
+ console.print(
32
+ f"[bold]agent[/bold] {provider} "
33
+ f"[dim](default; override per-turn with -P or `ags use`)[/dim]"
34
+ )
35
+ console.print(f"[bold]project[/bold] {st.get('project_dir', '?')}")
36
+ console.print(f"[bold]mode[/bold] {st.get('mode', '?')}")
cli/commands/task.py ADDED
@@ -0,0 +1,37 @@
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="Create and inspect tasks.")
10
+ console = Console()
11
+
12
+
13
+ @app.command("create")
14
+ def create(
15
+ objective: str = typer.Option(..., "--objective", "-o", help="What to accomplish."),
16
+ title: str = typer.Option("", "--title", "-t"),
17
+ workspace: str = typer.Option("default", "--workspace", "-w"),
18
+ ) -> None:
19
+ """Create a task."""
20
+ client = HarnessClient()
21
+ data = client.post(
22
+ "/tasks",
23
+ json={"objective": objective, "title": title or objective[:60], "workspace": workspace},
24
+ )
25
+ console.print(f"[green]Created task[/green] {data['id']}")
26
+ console.print_json(data=data)
27
+
28
+
29
+ @app.command("list")
30
+ def list_tasks(limit: int = typer.Option(20, "--limit")) -> None:
31
+ """List recent tasks."""
32
+ client = HarnessClient()
33
+ rows = client.get("/tasks", limit=limit)
34
+ table = Table("id", "title", "status")
35
+ for r in rows:
36
+ table.add_row(r["id"], r["title"], r["status"])
37
+ console.print(table)
cli/commands/usage.py ADDED
@@ -0,0 +1,86 @@
1
+ """``ags usage`` — all-providers usage table from the run ledger.
2
+
3
+ Shows a table of runs, tokens, and cost for **every** provider recorded in
4
+ the current workspace. For the per-provider view that also includes provider
5
+ plan limits (e.g. 5-hour / weekly quota), use ``ags quota [provider]``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import typer
11
+ from rich.console import Console
12
+ from rich.table import Table
13
+
14
+ from cli.client import HarnessClient
15
+
16
+ console = Console()
17
+
18
+
19
+ def usage(
20
+ workspace: str = typer.Option(
21
+ "default", "--workspace", "-w", envvar="HARNESS_WORKSPACE",
22
+ help="Workspace to query.",
23
+ ),
24
+ json_out: bool = typer.Option(False, "--json", help="Emit raw usage list as JSON and exit."),
25
+ ) -> None:
26
+ """Show all-providers usage totals from the run ledger.
27
+
28
+ Displays runs, tokens, and cost for every provider recorded in the
29
+ workspace. For the provider-scoped view that also includes plan limits,
30
+ use: [bold]ags quota[/bold]
31
+ """
32
+ client = HarnessClient()
33
+ rows = client.get("/usage", workspace=workspace)
34
+
35
+ if json_out:
36
+ console.print_json(data=rows)
37
+ return
38
+
39
+ if not rows:
40
+ console.print("[dim]No usage recorded yet.[/dim]")
41
+ return
42
+
43
+ table = Table(
44
+ title=f"AgentS usage — all providers (workspace: {workspace})",
45
+ title_justify="left",
46
+ )
47
+ for col in ("provider", "kind", "runs", "prompt tok", "completion tok", "total tok", "cost (USD)"):
48
+ table.add_column(col)
49
+
50
+ total_runs = 0
51
+ total_prompt = 0
52
+ total_completion = 0
53
+ total_tokens = 0
54
+ total_usd = 0.0
55
+
56
+ for row in rows:
57
+ table.add_row(
58
+ row["provider"],
59
+ row.get("kind", ""),
60
+ str(row["runs"]),
61
+ f"{row['prompt_tokens']:,}",
62
+ f"{row['completion_tokens']:,}",
63
+ f"{row['total_tokens']:,}",
64
+ f"${row['usd']:.4f}",
65
+ )
66
+ total_runs += int(row["runs"])
67
+ total_prompt += int(row["prompt_tokens"])
68
+ total_completion += int(row["completion_tokens"])
69
+ total_tokens += int(row["total_tokens"])
70
+ total_usd += float(row["usd"])
71
+
72
+ # Totals row
73
+ table.add_row(
74
+ "[bold]totals[/bold]", "",
75
+ f"[bold]{total_runs}[/bold]",
76
+ f"[bold]{total_prompt:,}[/bold]",
77
+ f"[bold]{total_completion:,}[/bold]",
78
+ f"[bold]{total_tokens:,}[/bold]",
79
+ f"[bold]${total_usd:.4f}[/bold]",
80
+ )
81
+
82
+ console.print(table)
83
+ console.print(
84
+ "[dim]AgentS's own accounting from the run ledger — "
85
+ "use [bold]ags quota[/bold] to see a single provider's plan limits.[/dim]\n"
86
+ )
cli/local.py ADDED
@@ -0,0 +1,84 @@
1
+ """Daemonless server management for the CLI.
2
+
3
+ A single ``ags serve`` process backed by SQLite. The CLI auto-spawns it on first use and
4
+ records its URL so every later command (and every terminal) finds it."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import shutil
10
+ import subprocess
11
+ import sys
12
+ import time
13
+ from pathlib import Path
14
+
15
+ import httpx
16
+
17
+ LOCAL_PORT = 8770
18
+ LOCAL_BASE_URL = f"http://localhost:{LOCAL_PORT}"
19
+ API_URL_FILE = Path.home() / ".ags" / "api_url"
20
+ PID_FILE = Path.home() / ".ags" / "server.pid"
21
+ LOG_FILE = Path.home() / ".ags" / "server.log"
22
+
23
+
24
+ def is_up(base_url: str) -> bool:
25
+ try:
26
+ r = httpx.get(f"{base_url}/api/v1/healthz", timeout=1.5)
27
+ return r.status_code == 200
28
+ except httpx.HTTPError:
29
+ return False
30
+
31
+
32
+ def _write_api_url(base_url: str) -> None:
33
+ API_URL_FILE.parent.mkdir(parents=True, exist_ok=True)
34
+ API_URL_FILE.write_text(base_url)
35
+
36
+
37
+ def _serve_command() -> list[str]:
38
+ """Command to launch the server using the INSTALLED package, never CWD code.
39
+
40
+ Prefer the ``ags`` console script (its ``sys.path`` is the bin dir, not the
41
+ current directory, so it can't be shadowed by a repo's own ``cli``/``harness``).
42
+ Fall back to ``python -P -m cli.main`` (``-P`` keeps CWD off ``sys.path``)."""
43
+ exe = shutil.which("ags") or (os.path.basename(sys.argv[0]) == "ags" and sys.argv[0])
44
+ if exe:
45
+ return [exe, "serve", "--port", str(LOCAL_PORT)]
46
+ return [sys.executable, "-P", "-m", "cli.main", "serve", "--port", str(LOCAL_PORT)]
47
+
48
+
49
+ def spawn_server(base_url: str = LOCAL_BASE_URL, *, wait_s: float = 30.0) -> bool:
50
+ """Start a detached ``ags serve`` and wait until it answers health checks."""
51
+ PID_FILE.parent.mkdir(parents=True, exist_ok=True)
52
+ LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
53
+ with open(LOG_FILE, "ab") as logf:
54
+ proc = subprocess.Popen(
55
+ _serve_command(),
56
+ stdout=logf, stderr=logf, stdin=subprocess.DEVNULL,
57
+ start_new_session=True, # detach: survives this CLI process exiting
58
+ )
59
+ PID_FILE.write_text(str(proc.pid))
60
+ deadline = time.monotonic() + wait_s
61
+ while time.monotonic() < deadline:
62
+ if is_up(base_url):
63
+ _write_api_url(base_url)
64
+ return True
65
+ if proc.poll() is not None: # server died on startup
66
+ return False
67
+ time.sleep(0.4)
68
+ return False
69
+
70
+
71
+ def ensure_local_server() -> str | None:
72
+ """Return a reachable API base URL, auto-spawning the server if needed.
73
+
74
+ Returns None when nothing could be reached (the caller's connect-error message
75
+ then guides the user). Honors an explicit ``HARNESS_API_BASE_URL``."""
76
+ if env := os.environ.get("HARNESS_API_BASE_URL"):
77
+ return env
78
+ # Prefer a previously recorded URL if it's live.
79
+ if API_URL_FILE.exists() and (url := API_URL_FILE.read_text().strip()) and is_up(url):
80
+ return url
81
+ if is_up(LOCAL_BASE_URL):
82
+ _write_api_url(LOCAL_BASE_URL)
83
+ return LOCAL_BASE_URL
84
+ return LOCAL_BASE_URL if spawn_server() else None