forgexa-cli 1.0.2__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.
- forgexa_cli/__init__.py +2 -0
- forgexa_cli/main.py +592 -0
- forgexa_cli/py.typed +0 -0
- forgexa_cli-1.0.2.dist-info/METADATA +183 -0
- forgexa_cli-1.0.2.dist-info/RECORD +8 -0
- forgexa_cli-1.0.2.dist-info/WHEEL +5 -0
- forgexa_cli-1.0.2.dist-info/entry_points.txt +2 -0
- forgexa_cli-1.0.2.dist-info/top_level.txt +1 -0
forgexa_cli/__init__.py
ADDED
forgexa_cli/main.py
ADDED
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Forgexa CLI — command-line client for the Forgexa platform.
|
|
3
|
+
|
|
4
|
+
A lightweight, standalone CLI that communicates with the Forgexa server via REST API.
|
|
5
|
+
Zero external dependencies — uses only Python stdlib.
|
|
6
|
+
|
|
7
|
+
Configuration:
|
|
8
|
+
FORGEXA_SERVER_URL Server URL (default: http://localhost:8000)
|
|
9
|
+
FORGEXA_TOKEN Bearer token (obtain via `forgexa login`)
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
forgexa login
|
|
13
|
+
forgexa workspace list
|
|
14
|
+
forgexa project list --workspace <id>
|
|
15
|
+
forgexa requirement list --project <id>
|
|
16
|
+
forgexa daemon status
|
|
17
|
+
forgexa gates pending
|
|
18
|
+
forgexa --help
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import getpass
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import signal
|
|
27
|
+
import sys
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
# ── HTTP helpers (stdlib only) ──
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _api_url() -> str:
|
|
34
|
+
return os.environ.get("FORGEXA_SERVER_URL") or os.environ.get("ASF_SERVER_URL", "http://localhost:8000")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _token() -> str | None:
|
|
38
|
+
# 1. Environment variable (new name, fallback to legacy)
|
|
39
|
+
token = os.environ.get("FORGEXA_TOKEN") or os.environ.get("ASF_TOKEN")
|
|
40
|
+
if token:
|
|
41
|
+
return token
|
|
42
|
+
# 2. Token file (new path, fallback to legacy)
|
|
43
|
+
for config_dir in [".forgexa", ".asf"]:
|
|
44
|
+
token_file = Path.home() / config_dir / "token"
|
|
45
|
+
if token_file.exists():
|
|
46
|
+
return token_file.read_text().strip()
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _headers() -> dict[str, str]:
|
|
51
|
+
h: dict[str, str] = {"Content-Type": "application/json"}
|
|
52
|
+
token = _token()
|
|
53
|
+
if token:
|
|
54
|
+
h["Authorization"] = f"Bearer {token}"
|
|
55
|
+
return h
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _get(path: str) -> dict | list:
|
|
59
|
+
import urllib.request
|
|
60
|
+
import urllib.error
|
|
61
|
+
|
|
62
|
+
url = f"{_api_url()}/api/v1{path}"
|
|
63
|
+
req = urllib.request.Request(url, headers=_headers())
|
|
64
|
+
try:
|
|
65
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
66
|
+
return json.loads(resp.read())
|
|
67
|
+
except urllib.error.HTTPError as e:
|
|
68
|
+
body = e.read().decode(errors="replace")
|
|
69
|
+
print(f"Error {e.code}: {body}", file=sys.stderr)
|
|
70
|
+
sys.exit(1)
|
|
71
|
+
except urllib.error.URLError as e:
|
|
72
|
+
print(f"Connection error: {e.reason}\nServer: {_api_url()}", file=sys.stderr)
|
|
73
|
+
sys.exit(1)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _post(path: str, data: dict | None = None) -> dict:
|
|
77
|
+
import urllib.request
|
|
78
|
+
import urllib.error
|
|
79
|
+
|
|
80
|
+
url = f"{_api_url()}/api/v1{path}"
|
|
81
|
+
body = json.dumps(data or {}).encode()
|
|
82
|
+
req = urllib.request.Request(url, data=body, headers=_headers(), method="POST")
|
|
83
|
+
try:
|
|
84
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
85
|
+
return json.loads(resp.read())
|
|
86
|
+
except urllib.error.HTTPError as e:
|
|
87
|
+
body_text = e.read().decode(errors="replace")
|
|
88
|
+
print(f"Error {e.code}: {body_text}", file=sys.stderr)
|
|
89
|
+
sys.exit(1)
|
|
90
|
+
except urllib.error.URLError as e:
|
|
91
|
+
print(f"Connection error: {e.reason}\nServer: {_api_url()}", file=sys.stderr)
|
|
92
|
+
sys.exit(1)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _delete(path: str) -> dict | None:
|
|
96
|
+
import urllib.request
|
|
97
|
+
import urllib.error
|
|
98
|
+
|
|
99
|
+
url = f"{_api_url()}/api/v1{path}"
|
|
100
|
+
req = urllib.request.Request(url, headers=_headers(), method="DELETE")
|
|
101
|
+
try:
|
|
102
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
103
|
+
content = resp.read()
|
|
104
|
+
return json.loads(content) if content else None
|
|
105
|
+
except urllib.error.HTTPError as e:
|
|
106
|
+
body = e.read().decode(errors="replace")
|
|
107
|
+
print(f"Error {e.code}: {body}", file=sys.stderr)
|
|
108
|
+
sys.exit(1)
|
|
109
|
+
except urllib.error.URLError as e:
|
|
110
|
+
print(f"Connection error: {e.reason}\nServer: {_api_url()}", file=sys.stderr)
|
|
111
|
+
sys.exit(1)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ── Output helpers ──
|
|
115
|
+
|
|
116
|
+
_output_format = "table"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _fmt() -> str:
|
|
120
|
+
return _output_format
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _print_table(headers: list[str], rows: list[list[str]], fmt: str | None = None) -> None:
|
|
124
|
+
fmt = fmt or _fmt()
|
|
125
|
+
if fmt == "json":
|
|
126
|
+
result = [dict(zip(headers, row)) for row in rows]
|
|
127
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
128
|
+
return
|
|
129
|
+
if fmt == "quiet":
|
|
130
|
+
for row in rows:
|
|
131
|
+
print(row[0] if row else "")
|
|
132
|
+
return
|
|
133
|
+
if not rows:
|
|
134
|
+
return
|
|
135
|
+
widths = [len(h) for h in headers]
|
|
136
|
+
for row in rows:
|
|
137
|
+
for i, cell in enumerate(row):
|
|
138
|
+
if i < len(widths):
|
|
139
|
+
widths[i] = max(widths[i], len(str(cell)))
|
|
140
|
+
line_fmt = " ".join(f"{{:<{w}}}" for w in widths)
|
|
141
|
+
print(line_fmt.format(*headers))
|
|
142
|
+
print(line_fmt.format(*("-" * w for w in widths)))
|
|
143
|
+
for row in rows:
|
|
144
|
+
print(line_fmt.format(*[str(c) for c in row]))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ── Commands ──
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def cmd_login(args: argparse.Namespace) -> None:
|
|
151
|
+
email = args.email or input("Email: ")
|
|
152
|
+
password = args.password or getpass.getpass("Password: ")
|
|
153
|
+
result = _post("/auth/login", {"email": email, "password": password})
|
|
154
|
+
token = result.get("access_token", "")
|
|
155
|
+
# Save token to file
|
|
156
|
+
token_dir = Path.home() / ".forgexa"
|
|
157
|
+
token_dir.mkdir(exist_ok=True)
|
|
158
|
+
(token_dir / "token").write_text(token)
|
|
159
|
+
(token_dir / "token").chmod(0o600)
|
|
160
|
+
print(f"Login successful. Token saved to ~/.forgexa/token")
|
|
161
|
+
print(f"Or set manually: export FORGEXA_TOKEN={token}")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def cmd_logout(_args: argparse.Namespace) -> None:
|
|
165
|
+
removed = False
|
|
166
|
+
for config_dir in [".forgexa", ".asf"]:
|
|
167
|
+
token_file = Path.home() / config_dir / "token"
|
|
168
|
+
if token_file.exists():
|
|
169
|
+
token_file.unlink()
|
|
170
|
+
removed = True
|
|
171
|
+
if removed:
|
|
172
|
+
print("Logged out. Token removed.")
|
|
173
|
+
else:
|
|
174
|
+
print("No token file found.")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def cmd_daemon_status(_args: argparse.Namespace) -> None:
|
|
178
|
+
runtimes = _get("/runtimes")
|
|
179
|
+
if not runtimes:
|
|
180
|
+
print("No daemons registered.")
|
|
181
|
+
return
|
|
182
|
+
_print_table(
|
|
183
|
+
["ID", "Daemon", "Device", "Status", "Active", "Max", "Heartbeat"],
|
|
184
|
+
[
|
|
185
|
+
[
|
|
186
|
+
r["id"][:8],
|
|
187
|
+
r["daemon_id"],
|
|
188
|
+
r.get("device_name", ""),
|
|
189
|
+
r["status"],
|
|
190
|
+
str(r.get("active_tasks", 0)),
|
|
191
|
+
str(r.get("max_concurrent_tasks", 0)),
|
|
192
|
+
r.get("last_heartbeat_at", "")[:19] if r.get("last_heartbeat_at") else "never",
|
|
193
|
+
]
|
|
194
|
+
for r in runtimes
|
|
195
|
+
],
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def cmd_daemon_stop(_args: argparse.Namespace) -> None:
|
|
200
|
+
# Check new path first, fallback to legacy
|
|
201
|
+
for pid_path in [".forgexa-daemon.pid", ".asf-daemon.pid"]:
|
|
202
|
+
pid_file = Path.home() / pid_path
|
|
203
|
+
if pid_file.exists():
|
|
204
|
+
break
|
|
205
|
+
else:
|
|
206
|
+
print("No daemon PID file found. Is a daemon running?")
|
|
207
|
+
sys.exit(1)
|
|
208
|
+
pid = int(pid_file.read_text().strip())
|
|
209
|
+
try:
|
|
210
|
+
os.kill(pid, signal.SIGTERM)
|
|
211
|
+
print(f"Sent SIGTERM to daemon (PID {pid})")
|
|
212
|
+
pid_file.unlink()
|
|
213
|
+
except ProcessLookupError:
|
|
214
|
+
print(f"Daemon process {pid} not found (already stopped?)")
|
|
215
|
+
pid_file.unlink()
|
|
216
|
+
except PermissionError:
|
|
217
|
+
print(f"Permission denied to stop daemon (PID {pid})")
|
|
218
|
+
sys.exit(1)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def cmd_runtimes_list(_args: argparse.Namespace) -> None:
|
|
222
|
+
runtimes = _get("/runtimes")
|
|
223
|
+
if not runtimes:
|
|
224
|
+
print("No runtimes registered.")
|
|
225
|
+
return
|
|
226
|
+
_print_table(
|
|
227
|
+
["ID", "Daemon", "Status", "Agents", "Active/Max"],
|
|
228
|
+
[
|
|
229
|
+
[
|
|
230
|
+
r["id"][:8],
|
|
231
|
+
r["daemon_id"],
|
|
232
|
+
r["status"],
|
|
233
|
+
", ".join(
|
|
234
|
+
a["agent_id"] for a in r.get("available_agents", []) if isinstance(a, dict)
|
|
235
|
+
),
|
|
236
|
+
f"{r.get('active_tasks', 0)}/{r.get('max_concurrent_tasks', 0)}",
|
|
237
|
+
]
|
|
238
|
+
for r in runtimes
|
|
239
|
+
],
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def cmd_workspace_list(_args: argparse.Namespace) -> None:
|
|
244
|
+
workspaces = _get("/workspaces")
|
|
245
|
+
if not workspaces:
|
|
246
|
+
print("No workspaces.")
|
|
247
|
+
return
|
|
248
|
+
_print_table(
|
|
249
|
+
["ID", "Name", "Budget Used/Limit"],
|
|
250
|
+
[
|
|
251
|
+
[
|
|
252
|
+
w["id"][:8],
|
|
253
|
+
w["name"],
|
|
254
|
+
"$%.2f / $%.2f" % (float(w.get('budget_used_usd') or 0), float(w.get('budget_monthly_limit_usd') or 0)),
|
|
255
|
+
]
|
|
256
|
+
for w in workspaces
|
|
257
|
+
],
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def cmd_workspace_create(args: argparse.Namespace) -> None:
|
|
262
|
+
result = _post("/workspaces", {"name": args.name})
|
|
263
|
+
print(f"Created workspace: {result.get('id', '')} — {result.get('name', '')}")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def cmd_project_list(args: argparse.Namespace) -> None:
|
|
267
|
+
projects = _get(f"/workspaces/{args.workspace}/projects")
|
|
268
|
+
if not projects:
|
|
269
|
+
print("No projects.")
|
|
270
|
+
return
|
|
271
|
+
_print_table(
|
|
272
|
+
["ID", "Key", "Name", "Status"],
|
|
273
|
+
[[p["id"][:8], p.get("project_key", ""), p["name"], p["status"]] for p in projects],
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def cmd_project_status(args: argparse.Namespace) -> None:
|
|
278
|
+
status = _get(f"/projects/{args.project}/status")
|
|
279
|
+
print(json.dumps(status, indent=2))
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def cmd_project_create(args: argparse.Namespace) -> None:
|
|
283
|
+
data = {"name": args.name, "tech_stack": args.stack or ""}
|
|
284
|
+
result = _post(f"/workspaces/{args.workspace}/projects", data)
|
|
285
|
+
print(f"Created project: {result.get('id', '')} — {result.get('name', '')}")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def cmd_requirement_list(args: argparse.Namespace) -> None:
|
|
289
|
+
reqs = _get(f"/projects/{args.project}/requirements")
|
|
290
|
+
if not reqs:
|
|
291
|
+
print("No requirements.")
|
|
292
|
+
return
|
|
293
|
+
_print_table(
|
|
294
|
+
["ID", "Title", "Status", "Priority", "Version"],
|
|
295
|
+
[
|
|
296
|
+
[r["id"][:8], r["title"][:40], r["status"], r["priority"], str(r.get("version", 1))]
|
|
297
|
+
for r in reqs
|
|
298
|
+
],
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def cmd_requirement_create(args: argparse.Namespace) -> None:
|
|
303
|
+
data = {"title": args.title, "description": args.description or ""}
|
|
304
|
+
result = _post(f"/projects/{args.project}/requirements", data)
|
|
305
|
+
print(f"Created requirement: {result.get('id', '')} — {result.get('title', '')}")
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def cmd_requirement_analyze(args: argparse.Namespace) -> None:
|
|
309
|
+
result = _post(f"/requirements/{args.id}/analyze")
|
|
310
|
+
print(f"Status: {result.get('status')}")
|
|
311
|
+
analysis = (result.get("metadata") or {}).get("analysis", {})
|
|
312
|
+
if analysis:
|
|
313
|
+
print(json.dumps(analysis, indent=2, ensure_ascii=False)[:3000])
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def cmd_board(args: argparse.Namespace) -> None:
|
|
317
|
+
board = _get(f"/projects/{args.project}/board")
|
|
318
|
+
columns = board.get("columns", {})
|
|
319
|
+
for col in ["backlog", "todo", "in_progress", "in_review", "done"]:
|
|
320
|
+
items = columns.get(col, [])
|
|
321
|
+
print(f"\n{'=' * 40}")
|
|
322
|
+
print(f" {col.upper()} ({len(items)})")
|
|
323
|
+
print(f"{'=' * 40}")
|
|
324
|
+
for item in items:
|
|
325
|
+
print(
|
|
326
|
+
f" [{item.get('type', '')}] {item['title'][:50]} ({item.get('priority', '')})"
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def cmd_gates_pending(_args: argparse.Namespace) -> None:
|
|
331
|
+
gates = _get("/gates/pending")
|
|
332
|
+
if not gates:
|
|
333
|
+
print("No pending gates.")
|
|
334
|
+
return
|
|
335
|
+
_print_table(
|
|
336
|
+
["ID", "Type", "Score", "Threshold", "Reviewer"],
|
|
337
|
+
[
|
|
338
|
+
[
|
|
339
|
+
g["id"][:8],
|
|
340
|
+
g.get("gate_type", ""),
|
|
341
|
+
f"{g.get('score', 0):.1f}",
|
|
342
|
+
f"{g.get('threshold', 0):.1f}",
|
|
343
|
+
g.get("reviewer_type", ""),
|
|
344
|
+
]
|
|
345
|
+
for g in gates
|
|
346
|
+
],
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def cmd_gate_approve(args: argparse.Namespace) -> None:
|
|
351
|
+
result = _post(f"/gates/{args.id}/approve", {"comment": args.comment or ""})
|
|
352
|
+
print(f"Gate {args.id[:8]}: {result.get('result')}")
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def cmd_gate_reject(args: argparse.Namespace) -> None:
|
|
356
|
+
result = _post(f"/gates/{args.id}/reject", {"comment": args.reason or "Rejected via CLI"})
|
|
357
|
+
print(f"Gate {args.id[:8]}: {result.get('result')}")
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def cmd_workflow_show(args: argparse.Namespace) -> None:
|
|
361
|
+
wf = _get(f"/projects/{args.project}/workflow")
|
|
362
|
+
if wf.get("raw_content"):
|
|
363
|
+
print(wf["raw_content"])
|
|
364
|
+
else:
|
|
365
|
+
print(json.dumps(wf, indent=2, ensure_ascii=False))
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def cmd_workflow_reload(args: argparse.Namespace) -> None:
|
|
369
|
+
result = _post(f"/projects/{args.project}/workflow/reload")
|
|
370
|
+
print(f"Workflow reloaded: status={result.get('status')}, version={result.get('version')}")
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def cmd_budget(args: argparse.Namespace) -> None:
|
|
374
|
+
if args.workspace:
|
|
375
|
+
data = _get(f"/workspaces/{args.workspace}/budget")
|
|
376
|
+
elif args.project:
|
|
377
|
+
data = _get(f"/projects/{args.project}/budget")
|
|
378
|
+
else:
|
|
379
|
+
print("Specify --workspace or --project", file=sys.stderr)
|
|
380
|
+
sys.exit(1)
|
|
381
|
+
print(json.dumps(data, indent=2))
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def cmd_execution_list(args: argparse.Namespace) -> None:
|
|
385
|
+
execs = _get(f"/projects/{args.project}/executions")
|
|
386
|
+
if not execs:
|
|
387
|
+
print("No executions.")
|
|
388
|
+
return
|
|
389
|
+
_print_table(
|
|
390
|
+
["ID", "Status", "Started", "Finished"],
|
|
391
|
+
[
|
|
392
|
+
[
|
|
393
|
+
e["id"][:8],
|
|
394
|
+
e["status"],
|
|
395
|
+
e.get("started_at", "")[:19] if e.get("started_at") else "",
|
|
396
|
+
e.get("finished_at", "")[:19] if e.get("finished_at") else "",
|
|
397
|
+
]
|
|
398
|
+
for e in execs
|
|
399
|
+
],
|
|
400
|
+
fmt=getattr(args, "format", "table"),
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def cmd_execution_start(args: argparse.Namespace) -> None:
|
|
405
|
+
result = _post(f"/executions/{args.execution_id}/start")
|
|
406
|
+
print(f"Execution {args.execution_id[:8]}: status={result.get('status')}")
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def cmd_version(_args: argparse.Namespace) -> None:
|
|
410
|
+
from forgexa_cli import __version__
|
|
411
|
+
|
|
412
|
+
print(f"forgexa {__version__}")
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
# ── Argument parser ──
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def main() -> None:
|
|
419
|
+
parser = argparse.ArgumentParser(
|
|
420
|
+
prog="forgexa",
|
|
421
|
+
description="Forgexa CLI — communicates with the Forgexa server via REST API.",
|
|
422
|
+
epilog=(
|
|
423
|
+
"Configuration:\n"
|
|
424
|
+
" FORGEXA_SERVER_URL Server URL (default: http://localhost:8000)\n"
|
|
425
|
+
" FORGEXA_TOKEN Bearer token (or use `forgexa login`)\n"
|
|
426
|
+
),
|
|
427
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
428
|
+
)
|
|
429
|
+
parser.add_argument(
|
|
430
|
+
"--server-url",
|
|
431
|
+
default=None,
|
|
432
|
+
help="Server URL (default: $FORGEXA_SERVER_URL or http://localhost:8000)",
|
|
433
|
+
)
|
|
434
|
+
parser.add_argument(
|
|
435
|
+
"--format",
|
|
436
|
+
choices=["table", "json", "quiet"],
|
|
437
|
+
default="table",
|
|
438
|
+
help="Output format",
|
|
439
|
+
)
|
|
440
|
+
sub = parser.add_subparsers(dest="command", help="Available commands")
|
|
441
|
+
|
|
442
|
+
# version
|
|
443
|
+
sub.add_parser("version", help="Show CLI version")
|
|
444
|
+
|
|
445
|
+
# auth
|
|
446
|
+
login_p = sub.add_parser("login", help="Login and save access token")
|
|
447
|
+
login_p.add_argument("--email", help="Email address")
|
|
448
|
+
login_p.add_argument("--password", help="Password")
|
|
449
|
+
sub.add_parser("logout", help="Remove saved access token")
|
|
450
|
+
|
|
451
|
+
# daemon (remote API only — use forgexa-daemon for local daemon management)
|
|
452
|
+
daemon_p = sub.add_parser("daemon", help="Daemon management (remote)")
|
|
453
|
+
daemon_sub = daemon_p.add_subparsers(dest="daemon_cmd")
|
|
454
|
+
daemon_sub.add_parser("status", help="Show all daemon statuses (from server)")
|
|
455
|
+
daemon_sub.add_parser("stop", help="Stop local daemon (sends SIGTERM)")
|
|
456
|
+
|
|
457
|
+
# runtimes
|
|
458
|
+
rt_p = sub.add_parser("runtimes", help="Runtime management")
|
|
459
|
+
rt_sub = rt_p.add_subparsers(dest="rt_cmd")
|
|
460
|
+
rt_sub.add_parser("list", help="List all runtimes")
|
|
461
|
+
|
|
462
|
+
# workspace
|
|
463
|
+
ws_p = sub.add_parser("workspace", help="Workspace management")
|
|
464
|
+
ws_sub = ws_p.add_subparsers(dest="ws_cmd")
|
|
465
|
+
ws_sub.add_parser("list", help="List workspaces")
|
|
466
|
+
wsc = ws_sub.add_parser("create", help="Create a workspace")
|
|
467
|
+
wsc.add_argument("name", help="Workspace name")
|
|
468
|
+
|
|
469
|
+
# project
|
|
470
|
+
proj_p = sub.add_parser("project", help="Project management")
|
|
471
|
+
proj_sub = proj_p.add_subparsers(dest="proj_cmd")
|
|
472
|
+
pl = proj_sub.add_parser("list", help="List projects")
|
|
473
|
+
pl.add_argument("--workspace", required=True, help="Workspace ID")
|
|
474
|
+
ps = proj_sub.add_parser("status", help="Project status")
|
|
475
|
+
ps.add_argument("--project", required=True, help="Project ID")
|
|
476
|
+
pc = proj_sub.add_parser("create", help="Create a project")
|
|
477
|
+
pc.add_argument("name", help="Project name")
|
|
478
|
+
pc.add_argument("--workspace", required=True, help="Workspace ID")
|
|
479
|
+
pc.add_argument("--stack", default=None, help="Tech stack")
|
|
480
|
+
|
|
481
|
+
# requirement
|
|
482
|
+
req_p = sub.add_parser("requirement", help="Requirement management")
|
|
483
|
+
req_sub = req_p.add_subparsers(dest="req_cmd")
|
|
484
|
+
rl = req_sub.add_parser("list", help="List requirements")
|
|
485
|
+
rl.add_argument("--project", required=True)
|
|
486
|
+
ra = req_sub.add_parser("analyze", help="Analyze a requirement")
|
|
487
|
+
ra.add_argument("--id", required=True)
|
|
488
|
+
rc = req_sub.add_parser("create", help="Create a requirement")
|
|
489
|
+
rc.add_argument("title", help="Requirement title")
|
|
490
|
+
rc.add_argument("--project", required=True, help="Project ID")
|
|
491
|
+
rc.add_argument("--description", default="", help="Requirement description")
|
|
492
|
+
|
|
493
|
+
# board
|
|
494
|
+
board_p = sub.add_parser("board", help="Show kanban board")
|
|
495
|
+
board_p.add_argument("--project", required=True)
|
|
496
|
+
|
|
497
|
+
# gates
|
|
498
|
+
gate_p = sub.add_parser("gates", help="Gate management")
|
|
499
|
+
gate_sub = gate_p.add_subparsers(dest="gate_cmd")
|
|
500
|
+
gate_sub.add_parser("pending", help="List pending gates")
|
|
501
|
+
ga = gate_sub.add_parser("approve", help="Approve a gate")
|
|
502
|
+
ga.add_argument("--gate", required=True, dest="id")
|
|
503
|
+
ga.add_argument("--comment", default="")
|
|
504
|
+
gr = gate_sub.add_parser("reject", help="Reject a gate")
|
|
505
|
+
gr.add_argument("--gate", required=True, dest="id")
|
|
506
|
+
gr.add_argument("--reason", default="Rejected via CLI")
|
|
507
|
+
|
|
508
|
+
# workflow
|
|
509
|
+
wf_p = sub.add_parser("workflow", help="Workflow policy management")
|
|
510
|
+
wf_sub = wf_p.add_subparsers(dest="wf_cmd")
|
|
511
|
+
wfs = wf_sub.add_parser("show", help="Show current workflow")
|
|
512
|
+
wfs.add_argument("--project", required=True)
|
|
513
|
+
wfr = wf_sub.add_parser("reload", help="Reload workflow from repo")
|
|
514
|
+
wfr.add_argument("--project", required=True)
|
|
515
|
+
|
|
516
|
+
# budget
|
|
517
|
+
budget_p = sub.add_parser("budget", help="Budget overview")
|
|
518
|
+
budget_p.add_argument("--workspace", default=None)
|
|
519
|
+
budget_p.add_argument("--project", default=None)
|
|
520
|
+
|
|
521
|
+
# executions
|
|
522
|
+
exec_p = sub.add_parser("run", help="Execution management")
|
|
523
|
+
exec_sub = exec_p.add_subparsers(dest="run_cmd")
|
|
524
|
+
el = exec_sub.add_parser("list", help="List executions")
|
|
525
|
+
el.add_argument("--project", required=True)
|
|
526
|
+
es = exec_sub.add_parser("start", help="Start a pending execution")
|
|
527
|
+
es.add_argument("execution_id", help="Execution graph ID")
|
|
528
|
+
|
|
529
|
+
args = parser.parse_args()
|
|
530
|
+
|
|
531
|
+
if args.server_url:
|
|
532
|
+
os.environ["FORGEXA_SERVER_URL"] = args.server_url
|
|
533
|
+
|
|
534
|
+
global _output_format
|
|
535
|
+
_output_format = args.format or "table"
|
|
536
|
+
|
|
537
|
+
cmd = args.command
|
|
538
|
+
if not cmd:
|
|
539
|
+
parser.print_help()
|
|
540
|
+
return
|
|
541
|
+
|
|
542
|
+
dispatch: dict = {
|
|
543
|
+
"version": cmd_version,
|
|
544
|
+
"login": cmd_login,
|
|
545
|
+
"logout": cmd_logout,
|
|
546
|
+
"daemon": lambda a: {
|
|
547
|
+
"status": cmd_daemon_status,
|
|
548
|
+
"stop": cmd_daemon_stop,
|
|
549
|
+
}.get(a.daemon_cmd, lambda _: daemon_p.print_help())(a),
|
|
550
|
+
"runtimes": lambda a: {
|
|
551
|
+
"list": cmd_runtimes_list,
|
|
552
|
+
}.get(a.rt_cmd, lambda _: rt_p.print_help())(a),
|
|
553
|
+
"workspace": lambda a: {
|
|
554
|
+
"list": cmd_workspace_list,
|
|
555
|
+
"create": cmd_workspace_create,
|
|
556
|
+
}.get(a.ws_cmd, lambda _: ws_p.print_help())(a),
|
|
557
|
+
"project": lambda a: {
|
|
558
|
+
"list": cmd_project_list,
|
|
559
|
+
"status": cmd_project_status,
|
|
560
|
+
"create": cmd_project_create,
|
|
561
|
+
}.get(a.proj_cmd, lambda _: proj_p.print_help())(a),
|
|
562
|
+
"requirement": lambda a: {
|
|
563
|
+
"list": cmd_requirement_list,
|
|
564
|
+
"analyze": cmd_requirement_analyze,
|
|
565
|
+
"create": cmd_requirement_create,
|
|
566
|
+
}.get(a.req_cmd, lambda _: req_p.print_help())(a),
|
|
567
|
+
"board": cmd_board,
|
|
568
|
+
"gates": lambda a: {
|
|
569
|
+
"pending": cmd_gates_pending,
|
|
570
|
+
"approve": cmd_gate_approve,
|
|
571
|
+
"reject": cmd_gate_reject,
|
|
572
|
+
}.get(a.gate_cmd, lambda _: gate_p.print_help())(a),
|
|
573
|
+
"workflow": lambda a: {
|
|
574
|
+
"show": cmd_workflow_show,
|
|
575
|
+
"reload": cmd_workflow_reload,
|
|
576
|
+
}.get(a.wf_cmd, lambda _: wf_p.print_help())(a),
|
|
577
|
+
"budget": cmd_budget,
|
|
578
|
+
"run": lambda a: {
|
|
579
|
+
"list": cmd_execution_list,
|
|
580
|
+
"start": cmd_execution_start,
|
|
581
|
+
}.get(a.run_cmd, lambda _: exec_p.print_help())(a),
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
handler = dispatch.get(cmd)
|
|
585
|
+
if handler:
|
|
586
|
+
handler(args)
|
|
587
|
+
else:
|
|
588
|
+
parser.print_help()
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
if __name__ == "__main__":
|
|
592
|
+
main()
|
forgexa_cli/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: forgexa-cli
|
|
3
|
+
Version: 1.0.2
|
|
4
|
+
Summary: Forgexa CLI — command-line client for managing workspaces, projects, requirements, and agent runtimes
|
|
5
|
+
Author-email: Shinetech <dev@shinetechsoftware.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://forgexa.net
|
|
8
|
+
Project-URL: Documentation, https://docs.forgexa.net
|
|
9
|
+
Project-URL: Repository, https://github.com/anthropics/ai-software-factory
|
|
10
|
+
Project-URL: Issues, https://github.com/anthropics/ai-software-factory/issues
|
|
11
|
+
Keywords: forgexa,ai,software-factory,cli,devops,agent
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
24
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
25
|
+
Requires-Python: >=3.9
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# Forgexa CLI
|
|
29
|
+
|
|
30
|
+
Command-line client for the [[Forgexa](https://forgexa.net) platform.
|
|
31
|
+
|
|
32
|
+
Lightweight, zero-dependency — communicates with the Forgexa server via REST API using only Python stdlib.
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
# From PyPI (recommended)
|
|
38
|
+
pip install forgexa-cli
|
|
39
|
+
|
|
40
|
+
# Or with pipx (isolated environment)
|
|
41
|
+
pipx install forgexa-cli
|
|
42
|
+
|
|
43
|
+
# Verify installation
|
|
44
|
+
forgexa version
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Development Installation
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
# Install from source (editable mode)
|
|
51
|
+
git clone https://github.com/anthropics/ai-software-factory.git
|
|
52
|
+
cd ai-software-factory/cli
|
|
53
|
+
pip install -e .
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Quick Start
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
# Configure server (default: http://localhost:8000)
|
|
60
|
+
export FORGEXA_SERVER_URL=https://your-server.example.com
|
|
61
|
+
|
|
62
|
+
# Login (saves token to ~/.forgexa/token)
|
|
63
|
+
asf login
|
|
64
|
+
|
|
65
|
+
# List workspaces
|
|
66
|
+
asf workspace list
|
|
67
|
+
|
|
68
|
+
# List projects
|
|
69
|
+
asf project list --workspace <workspace-id>
|
|
70
|
+
|
|
71
|
+
# Show kanban board
|
|
72
|
+
asf board --project <project-id>
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Commands
|
|
76
|
+
|
|
77
|
+
| Command | Description |
|
|
78
|
+
|---------|-------------|
|
|
79
|
+
| `forgexa login` | Login and save access token |
|
|
80
|
+
| `forgexa logout` | Remove saved token |
|
|
81
|
+
| `forgexa workspace list` | List workspaces |
|
|
82
|
+
| `forgexa workspace create <name>` | Create a workspace |
|
|
83
|
+
| `forgexa project list --workspace <id>` | List projects |
|
|
84
|
+
| `forgexa project create <name> --workspace <id>` | Create a project |
|
|
85
|
+
| `forgexa requirement list --project <id>` | List requirements |
|
|
86
|
+
| `forgexa requirement create <title> --project <id>` | Create a requirement |
|
|
87
|
+
| `forgexa requirement analyze --id <id>` | Analyze a requirement |
|
|
88
|
+
| `forgexa board --project <id>` | Show kanban board |
|
|
89
|
+
| `forgexa run list --project <id>` | List executions |
|
|
90
|
+
| `forgexa run start <execution-id>` | Start an execution |
|
|
91
|
+
| `forgexa gates pending` | List pending gates |
|
|
92
|
+
| `forgexa gates approve --gate <id>` | Approve a gate |
|
|
93
|
+
| `forgexa gates reject --gate <id>` | Reject a gate |
|
|
94
|
+
| `forgexa workflow show --project <id>` | Show workflow policy |
|
|
95
|
+
| `forgexa workflow reload --project <id>` | Reload workflow |
|
|
96
|
+
| `forgexa budget --workspace <id>` | Budget overview |
|
|
97
|
+
| `forgexa daemon status` | Show daemon statuses |
|
|
98
|
+
| `forgexa daemon stop` | Stop local daemon |
|
|
99
|
+
| `forgexa runtimes list` | List runtimes |
|
|
100
|
+
| `forgexa version` | Show CLI version |
|
|
101
|
+
|
|
102
|
+
## Configuration
|
|
103
|
+
|
|
104
|
+
| Variable | Default | Description |
|
|
105
|
+
|----------|---------|-------------|
|
|
106
|
+
| `FORGEXA_SERVER_URL` | `http://localhost:8000` | `Server base URL |
|
|
107
|
+
| `FORGEXA_TOKEN` | — | Bearer token (overrides `~/.forgexa/token`) |
|
|
108
|
+
|
|
109
|
+
## Output Format
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
asf workspace list # Table (default)
|
|
113
|
+
asf workspace list --format json # JSON
|
|
114
|
+
asf workspace list --format quiet # IDs only
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Daemon Management
|
|
118
|
+
|
|
119
|
+
The `forgexa` CLI handles remote daemon queries. To **start** a daemon locally,
|
|
120
|
+
use the `forgexa-daemon` command from the backend package:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
pip install ./backend
|
|
124
|
+
forgexa-daemon # starts the runtime daemon
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Publishing to PyPI
|
|
128
|
+
|
|
129
|
+
### Prerequisites
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
pip install build twine
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Build & Publish
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
cd cli/
|
|
139
|
+
|
|
140
|
+
# Build only (creates dist/)
|
|
141
|
+
./scripts/publish.sh build
|
|
142
|
+
|
|
143
|
+
# Publish to TestPyPI (for testing)
|
|
144
|
+
./scripts/publish.sh test
|
|
145
|
+
|
|
146
|
+
# Publish to PyPI (production)
|
|
147
|
+
./scripts/publish.sh
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Version Bumping
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
# Bump version (updates pyproject.toml + __init__.py)
|
|
154
|
+
./scripts/bump-version.sh 0.3.0
|
|
155
|
+
|
|
156
|
+
# Then commit, tag, and publish
|
|
157
|
+
git add -A && git commit -m "release(cli): v0.3.0"
|
|
158
|
+
git tag cli-v0.3.0
|
|
159
|
+
./scripts/publish.sh
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### PyPI Authentication
|
|
163
|
+
|
|
164
|
+
Configure via environment variables or `~/.pypirc`:
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
# Using API token (recommended)
|
|
168
|
+
export TWINE_USERNAME=__token__
|
|
169
|
+
export TWINE_PASSWORD=pypi-AgEIcH...
|
|
170
|
+
|
|
171
|
+
# Or create ~/.pypirc
|
|
172
|
+
cat > ~/.pypirc << 'EOF'
|
|
173
|
+
[pypi]
|
|
174
|
+
username = __token__
|
|
175
|
+
password = pypi-AgEIcH...
|
|
176
|
+
|
|
177
|
+
[testpypi]
|
|
178
|
+
repository = https://test.pypi.org/legacy/
|
|
179
|
+
username = __token__
|
|
180
|
+
password = pypi-AgEIcH...
|
|
181
|
+
EOF
|
|
182
|
+
chmod 600 ~/.pypirc
|
|
183
|
+
```
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
forgexa_cli/__init__.py,sha256=haJPS5qIm70bNh8_Q0up6H_-dxj12agqS3Y7MPZOal8,73
|
|
2
|
+
forgexa_cli/main.py,sha256=crKUI1a-kcm2aaDverjvFdVjy8Z-cDBA5CKZJ46eWjQ,20237
|
|
3
|
+
forgexa_cli/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
forgexa_cli-1.0.2.dist-info/METADATA,sha256=J2nwPvyVfAECfCqm0-fCSr04wgcXt8llA_zCPV5M-UI,5072
|
|
5
|
+
forgexa_cli-1.0.2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
6
|
+
forgexa_cli-1.0.2.dist-info/entry_points.txt,sha256=xsyhMUyXX5cIytreQJf_TvOjxNrXKxnpto7ERL0oUy8,50
|
|
7
|
+
forgexa_cli-1.0.2.dist-info/top_level.txt,sha256=fmUzrXOBX6YCXHLqfvVl1xAwdBIx25hx2XCpeo0N6DU,12
|
|
8
|
+
forgexa_cli-1.0.2.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
forgexa_cli
|