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.
@@ -0,0 +1,2 @@
1
+ """forgexa-cli — Forgexa command-line client."""
2
+ __version__ = "1.0.2"
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ forgexa = forgexa_cli.main:main
@@ -0,0 +1 @@
1
+ forgexa_cli