forgexa-cli 1.86.2__tar.gz → 1.86.4__tar.gz

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 (34) hide show
  1. {forgexa_cli-1.86.2 → forgexa_cli-1.86.4}/PKG-INFO +1 -1
  2. {forgexa_cli-1.86.2 → forgexa_cli-1.86.4}/forgexa_cli/__init__.py +1 -1
  3. {forgexa_cli-1.86.2 → forgexa_cli-1.86.4}/forgexa_cli/agent_core.py +70 -3
  4. {forgexa_cli-1.86.2 → forgexa_cli-1.86.4}/forgexa_cli/daemon.py +49 -7
  5. {forgexa_cli-1.86.2 → forgexa_cli-1.86.4}/forgexa_cli.egg-info/PKG-INFO +1 -1
  6. {forgexa_cli-1.86.2 → forgexa_cli-1.86.4}/pyproject.toml +1 -1
  7. {forgexa_cli-1.86.2 → forgexa_cli-1.86.4}/README.md +0 -0
  8. {forgexa_cli-1.86.2 → forgexa_cli-1.86.4}/forgexa_cli/_build_config.py +0 -0
  9. {forgexa_cli-1.86.2 → forgexa_cli-1.86.4}/forgexa_cli/_local_bind.py +0 -0
  10. {forgexa_cli-1.86.2 → forgexa_cli-1.86.4}/forgexa_cli/autoupgrade.py +0 -0
  11. {forgexa_cli-1.86.2 → forgexa_cli-1.86.4}/forgexa_cli/aws_evidence.py +0 -0
  12. {forgexa_cli-1.86.2 → forgexa_cli-1.86.4}/forgexa_cli/aws_logs_evidence.py +0 -0
  13. {forgexa_cli-1.86.2 → forgexa_cli-1.86.4}/forgexa_cli/main.py +0 -0
  14. {forgexa_cli-1.86.2 → forgexa_cli-1.86.4}/forgexa_cli/observability_contract.py +0 -0
  15. {forgexa_cli-1.86.2 → forgexa_cli-1.86.4}/forgexa_cli/observability_evidence.py +0 -0
  16. {forgexa_cli-1.86.2 → forgexa_cli-1.86.4}/forgexa_cli/py.typed +0 -0
  17. {forgexa_cli-1.86.2 → forgexa_cli-1.86.4}/forgexa_cli/runtime_evidence.py +0 -0
  18. {forgexa_cli-1.86.2 → forgexa_cli-1.86.4}/forgexa_cli/zcode_protocol.py +0 -0
  19. {forgexa_cli-1.86.2 → forgexa_cli-1.86.4}/forgexa_cli.egg-info/SOURCES.txt +0 -0
  20. {forgexa_cli-1.86.2 → forgexa_cli-1.86.4}/forgexa_cli.egg-info/dependency_links.txt +0 -0
  21. {forgexa_cli-1.86.2 → forgexa_cli-1.86.4}/forgexa_cli.egg-info/entry_points.txt +0 -0
  22. {forgexa_cli-1.86.2 → forgexa_cli-1.86.4}/forgexa_cli.egg-info/requires.txt +0 -0
  23. {forgexa_cli-1.86.2 → forgexa_cli-1.86.4}/forgexa_cli.egg-info/top_level.txt +0 -0
  24. {forgexa_cli-1.86.2 → forgexa_cli-1.86.4}/setup.cfg +0 -0
  25. {forgexa_cli-1.86.2 → forgexa_cli-1.86.4}/tests/test_auth_and_runtime_commands.py +0 -0
  26. {forgexa_cli-1.86.2 → forgexa_cli-1.86.4}/tests/test_autoupgrade.py +0 -0
  27. {forgexa_cli-1.86.2 → forgexa_cli-1.86.4}/tests/test_check_command.py +0 -0
  28. {forgexa_cli-1.86.2 → forgexa_cli-1.86.4}/tests/test_expiry_warnings_and_revoke.py +0 -0
  29. {forgexa_cli-1.86.2 → forgexa_cli-1.86.4}/tests/test_local_bind_commands.py +0 -0
  30. {forgexa_cli-1.86.2 → forgexa_cli-1.86.4}/tests/test_publish_script.py +0 -0
  31. {forgexa_cli-1.86.2 → forgexa_cli-1.86.4}/tests/test_runtime_credentials.py +0 -0
  32. {forgexa_cli-1.86.2 → forgexa_cli-1.86.4}/tests/test_session_credentials.py +0 -0
  33. {forgexa_cli-1.86.2 → forgexa_cli-1.86.4}/tests/test_silent_install.py +0 -0
  34. {forgexa_cli-1.86.2 → forgexa_cli-1.86.4}/tests/test_upgrade_observability.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.86.2
3
+ Version: 1.86.4
4
4
  Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
5
5
  Author-email: Jason Sun <dev.winds@gmail.com>
6
6
  License-Expression: MIT
@@ -1,2 +1,2 @@
1
1
  """forgexa-cli — Forgexa command-line client."""
2
- __version__ = "1.86.2"
2
+ __version__ = "1.86.4"
@@ -309,6 +309,14 @@ def requires_task_delivery_baseline(node_type: str, task_input: dict) -> bool:
309
309
 
310
310
 
311
311
  def extract_implementation_audit(text: str) -> dict | None:
312
+ return extract_agent_report(text, "implementation_audit")
313
+
314
+
315
+ def extract_execution_delivery(text: str) -> dict | None:
316
+ return extract_agent_report(text, "execution_delivery")
317
+
318
+
319
+ def extract_agent_report(text: str, field: str) -> dict | None:
312
320
  """Read the last complete audit envelope from mixed assistant output."""
313
321
  decoder = json.JSONDecoder()
314
322
  offset = 0
@@ -323,11 +331,11 @@ def extract_implementation_audit(text: str) -> dict | None:
323
331
  except json.JSONDecodeError:
324
332
  offset = start + 1
325
333
  continue
326
- if isinstance(value, dict) and "implementation_audit" in value:
327
- report = value["implementation_audit"] if isinstance(value["implementation_audit"], dict) else None
334
+ if isinstance(value, dict) and field in value:
335
+ report = value[field] if isinstance(value[field], dict) else None
328
336
  report_end = end
329
337
  offset = end
330
- if '"implementation_audit"' in text[report_end:]:
338
+ if json.dumps(field) in text[report_end:]:
331
339
  return None
332
340
  return report
333
341
 
@@ -774,6 +782,51 @@ def lock_provenance_projection(path: str, source: str) -> str | None:
774
782
  }, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
775
783
 
776
784
 
785
+ def implementation_delivery_contract(node_type: str, task_input: dict, *, supported: bool, graph_type: str = "execution") -> dict | None:
786
+ if supported is not True or not isinstance(node_type, str) or not isinstance(graph_type, str) or node_type not in {"design", "coding", "testing", "review", "fix"} or graph_type not in {"execution", "implementation", "fix"}:
787
+ return None
788
+ if task_input.get("verification_work_item") or task_input.get("execution_actor") == "human":
789
+ return None
790
+ recovery = task_input.get("verification_recovery")
791
+ if isinstance(recovery, dict) and recovery.get("mode") == "review_only" and not task_input.get("implementation_delivery"):
792
+ return None
793
+ baseline = task_input.get("approved_plan_baseline")
794
+ if not isinstance(baseline, dict) or assess_approved_plan_conformance(baseline, None).get("reason") == "approved_plan_baseline_invalid":
795
+ return None
796
+ task_id = (baseline.get("task") or {}).get("id")
797
+ if not isinstance(task_id, str) or not task_id:
798
+ return None
799
+ contract = {"version": 1, "mode": "execution_and_artifacts", "node_type": node_type, "graph_type": graph_type,
800
+ "task_id": task_id, "baseline_fingerprint": baseline.get("fingerprint"),
801
+ "qa_policy": task_input.get("qa_test_level_policy_snapshot"), "change_scope": task_input.get("change_scope")}
802
+ contract["fingerprint"] = hashlib.sha256(json.dumps(contract, sort_keys=True, ensure_ascii=True, separators=(",", ":")).encode()).hexdigest()
803
+ return contract
804
+
805
+
806
+ def uses_controlled_delivery(task_input: dict) -> bool:
807
+ contract = task_input.get("implementation_delivery")
808
+ if not isinstance(contract, dict):
809
+ return False
810
+ expected = implementation_delivery_contract(contract.get("node_type"), task_input, supported=True, graph_type=contract.get("graph_type"))
811
+ return expected is not None and contract == expected
812
+
813
+
814
+ def assess_execution_delivery(contract: dict, report: object) -> dict:
815
+ if not isinstance(report, dict) or type(report.get("version")) is not int or report["version"] != 1:
816
+ return {"report_valid": False, "status": "unknown", "issues": ["Execution delivery declaration is missing or invalid"]}
817
+ if set(report) != {"version", "contract_fingerprint", "status"} or report.get("contract_fingerprint") != contract.get("fingerprint") or not isinstance(report.get("status"), str) or report["status"] not in {"complete", "partial", "blocked"}:
818
+ return {"report_valid": False, "status": "unknown", "issues": ["Execution delivery declaration does not match the current contract"]}
819
+ return {"report_valid": True, "status": report["status"], "issues": []}
820
+
821
+
822
+ def redact_execution_delivery(report: object) -> dict | None:
823
+ if not isinstance(report, dict):
824
+ return None
825
+ return {"version": report.get("version") if type(report.get("version")) is int else 0,
826
+ "contract_fingerprint": report.get("contract_fingerprint") if isinstance(report.get("contract_fingerprint"), str) and re.fullmatch(r"[0-9a-f]{64}", report["contract_fingerprint"]) else "",
827
+ "status": report.get("status") if report.get("status") in ("complete", "partial", "blocked") else "unknown"}
828
+
829
+
777
830
  def task_review_policy(node_type: str, task_input: dict, *, changed_files: list[str] | None = None, policy_supported: bool = True) -> dict:
778
831
  baseline = task_input.get("approved_plan_baseline")
779
832
  reasons = []
@@ -810,6 +863,8 @@ def task_review_policy(node_type: str, task_input: dict, *, changed_files: list[
810
863
  if node_type in {"coding", "fix"} and files and all(path.startswith("docs/") for path in files):
811
864
  reasons.append("scope_unknown")
812
865
  decision = {"version": 1, "mode": "required" if reasons else "advisory", "reasons": sorted(set(reasons))}
866
+ if uses_controlled_delivery(task_input):
867
+ decision.update(mode="advisory", decision_scope="controlled_delivery", delivery_contract=task_input["implementation_delivery"]["fingerprint"])
813
868
  decision["fingerprint"] = hashlib.sha256(json.dumps({
814
869
  **decision, "baseline_fingerprint": fingerprint,
815
870
  "qa_policy": str(task_input.get("qa_test_level_policy_fingerprint") or ""),
@@ -819,6 +874,8 @@ def task_review_policy(node_type: str, task_input: dict, *, changed_files: list[
819
874
 
820
875
 
821
876
  def review_blocks_completion(policy: dict, assessment: dict) -> bool:
877
+ if policy.get("decision_scope") == "controlled_delivery":
878
+ return False
822
879
  return not assessment.get("node_complete", assessment.get("status") == "conformant") and (
823
880
  policy.get("mode") != "advisory" or assessment.get("status") == "deviation"
824
881
  )
@@ -2168,6 +2225,7 @@ def _extract_output_signals(text: str, usage_source: object = None) -> dict[str,
2168
2225
  contains words like 'rate limit', 'connection error', 'API error', etc.
2169
2226
  (e.g. implementing a rate-limiter feature, or documenting error codes).
2170
2227
  """
2228
+ terminal_status = None
2171
2229
  has_turn_completed = False
2172
2230
  has_turn_failed = False
2173
2231
  has_result = False
@@ -2193,10 +2251,12 @@ def _extract_output_signals(text: str, usage_source: object = None) -> dict[str,
2193
2251
  ev_type = str(data.get("type", ""))
2194
2252
 
2195
2253
  if ev_type == "turn.completed":
2254
+ terminal_status = "success"
2196
2255
  has_turn_completed = True
2197
2256
  has_turn_failed = False
2198
2257
  error_messages.clear()
2199
2258
  elif ev_type == "turn.failed":
2259
+ terminal_status = "failed"
2200
2260
  has_turn_failed = True
2201
2261
  err = data.get("error") or {}
2202
2262
  if isinstance(err, dict):
@@ -2229,19 +2289,23 @@ def _extract_output_signals(text: str, usage_source: object = None) -> dict[str,
2229
2289
  if type(raw_exit_code) is not int and not (
2230
2290
  isinstance(raw_exit_code, str) and re.fullmatch(r"[+-]?[0-9]+", raw_exit_code)
2231
2291
  ):
2292
+ terminal_status = "failed"
2232
2293
  error_messages.append("Agent result has an invalid exitCode")
2233
2294
  continue
2234
2295
  exit_code = int(raw_exit_code)
2235
2296
  if exit_code == 0:
2297
+ terminal_status = "success"
2236
2298
  has_result = True
2237
2299
  has_meaningful_content = True
2238
2300
  has_turn_failed = False
2239
2301
  error_messages.clear()
2240
2302
  else:
2303
+ terminal_status = "failed"
2241
2304
  error_messages.append(f"Copilot exited with code {exit_code}")
2242
2305
  else:
2243
2306
  result_text = str(data.get("result", "") or "")
2244
2307
  if data.get("is_error"):
2308
+ terminal_status = "failed"
2245
2309
  raw_errors = data.get("errors")
2246
2310
  if isinstance(raw_errors, list):
2247
2311
  err_text = "; ".join(
@@ -2269,8 +2333,10 @@ def _extract_output_signals(text: str, usage_source: object = None) -> dict[str,
2269
2333
  # rather than the agent's actual work output.
2270
2334
  no_api_call = not has_agent_token_usage(data, usage_source) and not has_assistant_events
2271
2335
  if no_api_call and result_text:
2336
+ terminal_status = "failed"
2272
2337
  error_messages.append(result_text)
2273
2338
  else:
2339
+ terminal_status = "success"
2274
2340
  has_result = True
2275
2341
  has_meaningful_content = True
2276
2342
  has_turn_failed = False
@@ -2314,6 +2380,7 @@ def _extract_output_signals(text: str, usage_source: object = None) -> dict[str,
2314
2380
  has_meaningful_content = True
2315
2381
 
2316
2382
  return {
2383
+ "terminal_status": "failed" if error_messages or has_turn_failed else terminal_status,
2317
2384
  "has_turn_completed": has_turn_completed,
2318
2385
  "has_turn_failed": has_turn_failed,
2319
2386
  "has_result": has_result,
@@ -1234,7 +1234,7 @@ except (ImportError, ModuleNotFoundError):
1234
1234
  # DAEMON_VERSION is the protocol/logic version of the daemon code.
1235
1235
  # Kept in sync with pyproject.toml version via bump-version.sh.
1236
1236
  # CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
1237
- DAEMON_VERSION = "1.86.2"
1237
+ DAEMON_VERSION = "1.86.4"
1238
1238
 
1239
1239
 
1240
1240
  def _detect_client_type() -> str:
@@ -6502,6 +6502,8 @@ class ProcessManager:
6502
6502
  signals = agent_core._extract_output_signals(
6503
6503
  "\n".join(part for part in (stdout, stderr) if part), result.metrics,
6504
6504
  )
6505
+ if result.exit_code == 0 and not error and (signals.get("terminal_status") == "success" or result.has_verified_completion is True and signals.get("terminal_status") != "failed"):
6506
+ error_channels = ""
6505
6507
  if not has_token_usage and ProcessManager._should_scan_short_success_stdout(stdout, signals):
6506
6508
  error_channels = "\n".join(filter(None, [error_channels, stdout.strip()]))
6507
6509
  # Always include structured error messages parsed from JSONL stream events
@@ -10221,6 +10223,7 @@ class ServerConnection:
10221
10223
  "task_delivery": {"protocol_version": 1},
10222
10224
  "verification_recovery": {"protocol_version": 1},
10223
10225
  "task_review_policy": {"protocol_version": 1},
10226
+ "implementation_delivery": {"protocol_version": 1},
10224
10227
  "recording_suggestions": {"protocol_version": 1, "isolated_workspace": True},
10225
10228
  "task_attachment_staging": {"protocol_version": 1, "runtime_proxy": True, "image_agents": ["claude"]},
10226
10229
  },
@@ -10649,6 +10652,7 @@ class ServerConnection:
10649
10652
  "task_delivery": {"protocol_version": 1},
10650
10653
  "verification_recovery": {"protocol_version": 1},
10651
10654
  "task_review_policy": {"protocol_version": 1},
10655
+ "implementation_delivery": {"protocol_version": 1},
10652
10656
  "recording_suggestions": {"protocol_version": 1, "isolated_workspace": True},
10653
10657
  "local_evidence": {"protocol_version": 1, "structured_summary": True},
10654
10658
  "task_attachment_staging": {"protocol_version": 1, "runtime_proxy": True, "image_agents": ["claude"]},
@@ -11438,6 +11442,20 @@ class RuntimeDaemon:
11438
11442
  if managed_policy is not _MANAGED_WSL_POLICY:
11439
11443
  raise ValueError("wsl_runtime_entry_not_admitted")
11440
11444
  self.managed_policy = managed_policy
11445
+ # Ops-tunable staging budget. Keep aligned with the server's
11446
+ # MAX_ANALYSIS_ATTACHMENT_COUNT: the server only admits analysis
11447
+ # selections within its own budget, so a lower value here would
11448
+ # reject tasks the server considered valid.
11449
+ try:
11450
+ self._TASK_ATTACHMENT_MAX_COUNT = max(
11451
+ 1, int(os.environ.get("DAEMON_TASK_ATTACHMENT_MAX_COUNT", self._TASK_ATTACHMENT_MAX_COUNT))
11452
+ )
11453
+ except ValueError:
11454
+ logger.warning(
11455
+ "Ignoring invalid DAEMON_TASK_ATTACHMENT_MAX_COUNT=%r; using %d",
11456
+ os.environ.get("DAEMON_TASK_ATTACHMENT_MAX_COUNT"),
11457
+ self._TASK_ATTACHMENT_MAX_COUNT,
11458
+ )
11441
11459
  # Compute stable hardware fingerprint from machine-id / MAC address.
11442
11460
  # This allows the server to identify the same physical machine even when
11443
11461
  # its IP address changes (e.g., DHCP reassignment, home vs. office).
@@ -14619,6 +14637,16 @@ class RuntimeDaemon:
14619
14637
  issues: list[str] = []
14620
14638
  node_type = task.node_type
14621
14639
  input_data = task.input_data or {}
14640
+ controlled_delivery = agent_core.uses_controlled_delivery(input_data)
14641
+ if controlled_delivery:
14642
+ report = agent_core.extract_execution_delivery(_daemon_extract_agent_output(str(result.metrics.get("actual_agent") or getattr(task, "agent_type", "") or ""), result.stdout))
14643
+ delivery = agent_core.assess_execution_delivery(input_data["implementation_delivery"], report)
14644
+ result.metrics["execution_delivery"] = agent_core.redact_execution_delivery(report)
14645
+ result.metrics["execution_delivery_valid"] = delivery["report_valid"]
14646
+ if not delivery["report_valid"]:
14647
+ issues.extend(_OutputValidationIssue("validation_unavailable", message) for message in delivery["issues"])
14648
+ elif delivery["status"] != "complete":
14649
+ issues.append(_OutputValidationIssue("planned_task_incomplete", "Agent explicitly reported incomplete execution delivery"))
14622
14650
  fix_context = input_data.get("fix_context")
14623
14651
  if node_type == "testing" or (
14624
14652
  node_type == "fix" and input_data.get("task") == "phase_remediation_fix"
@@ -14651,18 +14679,18 @@ class RuntimeDaemon:
14651
14679
  result.observations.append({"type": "implementation_audit", "data": audit_report})
14652
14680
  audit_result = agent_core.assess_implementation_audit(audit_contract, audit_report)
14653
14681
  failure_domain = agent_core.implementation_audit_failure_domain(audit_contract, audit_result)
14654
- if failure_domain is not None:
14682
+ if failure_domain is not None and not controlled_delivery:
14655
14683
  result.metrics["failure_domain"] = failure_domain
14656
14684
  result.metrics["implementation_audit_node_complete"] = bool(
14657
- audit_result["report_valid"] and audit_result["node_complete"]
14685
+ controlled_delivery or audit_result["report_valid"] and audit_result["node_complete"]
14658
14686
  )
14659
14687
  result.metrics["implementation_audit_subject_blocked"] = bool(
14660
- audit_result["report_valid"] and not audit_result["node_complete"]
14688
+ not controlled_delivery and audit_result["report_valid"] and not audit_result["node_complete"]
14661
14689
  and (failure_domain == "environment" or any(
14662
14690
  finding.get("task_id") == audit_contract.get("subject_task_id") and finding.get("status") == "blocked"
14663
14691
  for finding in audit_result.get("findings") or []))
14664
14692
  )
14665
- if audit_result["report_valid"] and audit_result["status"] == "plan_revision_required":
14693
+ if not controlled_delivery and audit_result["report_valid"] and audit_result["status"] == "plan_revision_required":
14666
14694
  result.status = "failed"
14667
14695
  result.failure_code = "implementation_plan_revision_required"
14668
14696
  requests = audit_result.get("blocking_revision_requests", audit_result["revision_requests"])
@@ -14670,7 +14698,7 @@ class RuntimeDaemon:
14670
14698
  f"{request['task_id']}: {request['reason']}" for request in requests[:3]
14671
14699
  )
14672
14700
  issues.append(_OutputValidationIssue(result.failure_code, result.error))
14673
- elif not audit_result["report_valid"] or not audit_result["node_complete"]:
14701
+ elif not controlled_delivery and (not audit_result["report_valid"] or not audit_result["node_complete"]):
14674
14702
  issues.extend(_OutputValidationIssue("implementation_audit_incomplete", issue) for issue in (
14675
14703
  audit_result["issues"] or audit_result.get("blocking_reasons") or [
14676
14704
  "The selected approved task still has implementation gaps",
@@ -15211,7 +15239,9 @@ class RuntimeDaemon:
15211
15239
  ac for ac in ac_list
15212
15240
  if ac["status"] != "covered" and ac["id"] not in pending
15213
15241
  ]
15214
- if uncovered:
15242
+ if uncovered and controlled_delivery:
15243
+ result.metrics["business_acceptance"] = {"status": "pending_human_qa", "criteria": [ac["id"] for ac in uncovered]}
15244
+ elif uncovered:
15215
15245
  ids = ", ".join(ac["id"] for ac in uncovered[:20])
15216
15246
  if len(uncovered) > 20:
15217
15247
  ids += f" (+{len(uncovered) - 20} more)"
@@ -16686,6 +16716,18 @@ class RuntimeDaemon:
16686
16716
  *[f"[daemon] {issue}" for issue in issues],
16687
16717
  ])
16688
16718
 
16719
+ if (
16720
+ agent_core.uses_controlled_delivery(task.input_data or {})
16721
+ and result.metrics.get("execution_delivery_valid") is True
16722
+ and (result.metrics.get("execution_delivery") or {}).get("status") in {"partial", "blocked"}
16723
+ ):
16724
+ result.status = "failed"
16725
+ result.failure_code = "planned_task_incomplete"
16726
+ result.error = "Agent explicitly reported incomplete execution delivery; preserve work and resolve prerequisites before an authorized retry"
16727
+ _record_output_validation_issues(result, issues)
16728
+ self._record_testing_telemetry(workspace_path, task, result, retries_used=attempt)
16729
+ return result
16730
+
16689
16731
  if (
16690
16732
  result.metrics.get("failure_domain") == "environment" and all(
16691
16733
  issue.code in {"implementation_audit_incomplete", "acceptance_criteria_uncovered"} for issue in issues
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.86.2
3
+ Version: 1.86.4
4
4
  Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
5
5
  Author-email: Jason Sun <dev.winds@gmail.com>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "forgexa-cli"
3
- version = "1.86.2"
3
+ version = "1.86.4"
4
4
  description = "Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform"
5
5
  requires-python = ">=3.9"
6
6
  license = "MIT"
File without changes
File without changes