@grifhinz/logics-manager 2.8.0 → 2.8.1

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.
@@ -49,6 +49,8 @@ CDX_MISSION_STRENGTHS = {
49
49
  "deep": {"id": "deep", "label": "Deep", "timeout": 300, "reasoningEffort": "high", "power": "high"},
50
50
  "max": {"id": "max", "label": "Max", "timeout": 600, "reasoningEffort": "high", "power": "high"},
51
51
  }
52
+ CDX_MISSION_PARENT_TIMEOUT_GRACE_SECONDS = 90
53
+ CDX_WRITABLE_MISSION_MIN_TIMEOUT_SECONDS = 600
52
54
  CDX_MISSION_CATALOG = {
53
55
  "full-audit": {
54
56
  "id": "full-audit",
@@ -142,6 +144,26 @@ GIT_FILE_PREVIEW_MAX_BYTES = 30000
142
144
  GIT_FILE_PREVIEW_MAX_CHARS = 20000
143
145
  FILE_PREVIEW_MAX_BYTES = 300000
144
146
  FILE_PREVIEW_MAX_CHARS = 200000
147
+ WORKSPACE_TREE_MAX_ENTRIES = 250
148
+ WORKSPACE_PREVIEW_MAX_BYTES = 30000
149
+ WORKSPACE_PREVIEW_MAX_CHARS = 20000
150
+ WORKSPACE_IGNORED_DIRS = {
151
+ ".git",
152
+ ".hg",
153
+ ".svn",
154
+ "__pycache__",
155
+ ".pytest_cache",
156
+ ".mypy_cache",
157
+ ".ruff_cache",
158
+ "node_modules",
159
+ "dist",
160
+ "build",
161
+ "coverage",
162
+ ".next",
163
+ ".turbo",
164
+ ".venv",
165
+ "venv",
166
+ }
145
167
  REPO_ROOT = Path(__file__).resolve().parents[1]
146
168
  PACKAGE_VIEWER_ASSETS_ROOT = Path(__file__).resolve().parent / "viewer_assets"
147
169
  VIEWER_ROOT = REPO_ROOT / "clients" / "viewer"
@@ -442,6 +464,7 @@ def viewer_data_payload(
442
464
  selected_id: str | None = None,
443
465
  *,
444
466
  auto_refresh_interval_seconds: int = 15,
467
+ auto_refresh_interval_forced: bool = False,
445
468
  projects: list[dict[str, Any]] | None = None,
446
469
  ) -> dict[str, Any]:
447
470
  capabilities = viewer_project_capabilities(repo_root)
@@ -457,6 +480,7 @@ def viewer_data_payload(
457
480
  "capabilities": capabilities,
458
481
  "projects": projects if projects is not None else viewer_project_registry(repo_root),
459
482
  "autoRefreshIntervalSeconds": auto_refresh_interval_seconds,
483
+ "autoRefreshIntervalForced": auto_refresh_interval_forced,
460
484
  "items": collect_viewer_items(repo_root),
461
485
  "updateInfo": get_update_info(_current_version()).to_payload(),
462
486
  "selectedId": selected_id,
@@ -611,9 +635,16 @@ def viewer_project_capabilities(
611
635
  else:
612
636
  cdx = _viewer_capability("missing", available=False, message="CDX executable is not available.")
613
637
  cdx_runs = _viewer_capability("missing", available=False, message="CDX is required before assistant runs can be tracked.")
638
+ workspace = _viewer_capability(
639
+ "ready" if repo_root.is_dir() else "missing",
640
+ available=repo_root.is_dir(),
641
+ message="Workspace root can be inspected." if repo_root.is_dir() else "Workspace root is unavailable.",
642
+ detail={"root": str(repo_root.resolve())} if repo_root.is_dir() else {},
643
+ )
614
644
 
615
645
  return {
616
646
  "logics": logics,
647
+ "workspace": workspace,
617
648
  "git": git,
618
649
  "ci": ci,
619
650
  "cdx": cdx,
@@ -1156,6 +1187,168 @@ def git_file_preview_payload(
1156
1187
  }
1157
1188
 
1158
1189
 
1190
+ def _normalize_workspace_path(rel_path: str) -> str:
1191
+ normalized = unquote(rel_path or "").replace("\\", "/").strip()
1192
+ normalized = normalized.lstrip("/")
1193
+ if normalized in {"", "."}:
1194
+ return ""
1195
+ if normalized.startswith("~") or re.match(r"^[A-Za-z]:", normalized):
1196
+ raise ValueError("Unsafe workspace path.")
1197
+ parts = [part for part in normalized.split("/") if part not in {"", "."}]
1198
+ if any(part == ".." for part in parts):
1199
+ raise ValueError("Workspace path escapes root.")
1200
+ return "/".join(parts)
1201
+
1202
+
1203
+ def _resolve_workspace_path(repo_root: Path, rel_path: str) -> tuple[str, Path]:
1204
+ normalized = _normalize_workspace_path(rel_path)
1205
+ root = repo_root.resolve()
1206
+ target = (root / normalized).resolve()
1207
+ try:
1208
+ target.relative_to(root)
1209
+ except ValueError as exc:
1210
+ raise ValueError("Workspace path escapes root.") from exc
1211
+ return normalized, target
1212
+
1213
+
1214
+ def _workspace_entry_payload(root: Path, path: Path) -> dict[str, Any]:
1215
+ try:
1216
+ stat = path.stat()
1217
+ except OSError:
1218
+ stat = None
1219
+ rel_path = path.relative_to(root).as_posix()
1220
+ is_dir = path.is_dir()
1221
+ ignored = is_dir and path.name in WORKSPACE_IGNORED_DIRS
1222
+ return {
1223
+ "name": path.name or root.name,
1224
+ "path": rel_path,
1225
+ "kind": "directory" if is_dir else "file",
1226
+ "size": stat.st_size if stat else 0,
1227
+ "ignored": ignored,
1228
+ "childrenAvailable": is_dir and not ignored,
1229
+ }
1230
+
1231
+
1232
+ def workspace_tree_payload(
1233
+ repo_root: Path,
1234
+ rel_path: str = "",
1235
+ *,
1236
+ max_entries: int = WORKSPACE_TREE_MAX_ENTRIES,
1237
+ ) -> dict[str, Any]:
1238
+ normalized, target = _resolve_workspace_path(repo_root, rel_path)
1239
+ root = repo_root.resolve()
1240
+ if not target.exists():
1241
+ return {"state": "missing", "path": normalized, "message": "Workspace path does not exist."}
1242
+ if not target.is_dir():
1243
+ return {"state": "not-directory", "path": normalized, "message": "Workspace path is not a directory."}
1244
+ entries = []
1245
+ truncated = False
1246
+ try:
1247
+ children = sorted(target.iterdir(), key=lambda path: (not path.is_dir(), path.name.lower()))
1248
+ except OSError as exc:
1249
+ return {"state": "error", "path": normalized, "message": f"Unable to list workspace path: {exc}"}
1250
+ for child in children:
1251
+ if len(entries) >= max_entries:
1252
+ truncated = True
1253
+ break
1254
+ entries.append(_workspace_entry_payload(root, child))
1255
+ return {
1256
+ "state": "ok",
1257
+ "root": str(root),
1258
+ "path": normalized,
1259
+ "entries": entries,
1260
+ "truncated": truncated,
1261
+ "ignoredDirectories": sorted(WORKSPACE_IGNORED_DIRS),
1262
+ }
1263
+
1264
+
1265
+ def workspace_preview_payload(
1266
+ repo_root: Path,
1267
+ rel_path: str,
1268
+ *,
1269
+ max_bytes: int = WORKSPACE_PREVIEW_MAX_BYTES,
1270
+ max_chars: int = WORKSPACE_PREVIEW_MAX_CHARS,
1271
+ ) -> dict[str, Any]:
1272
+ normalized, target = _resolve_workspace_path(repo_root, rel_path)
1273
+ if not target.exists():
1274
+ return {"state": "missing", "path": normalized, "message": "Workspace path does not exist."}
1275
+ if target.is_dir():
1276
+ try:
1277
+ count = sum(1 for _ in target.iterdir())
1278
+ except OSError:
1279
+ count = 0
1280
+ return {
1281
+ "state": "directory",
1282
+ "path": normalized,
1283
+ "name": target.name or repo_root.resolve().name,
1284
+ "kind": "directory",
1285
+ "message": f"{count} item(s)",
1286
+ "childrenAvailable": target.name not in WORKSPACE_IGNORED_DIRS,
1287
+ }
1288
+ if not target.is_file():
1289
+ return {"state": "unsupported", "path": normalized, "message": "Workspace object cannot be previewed."}
1290
+ try:
1291
+ size = target.stat().st_size
1292
+ except OSError as exc:
1293
+ return {"state": "error", "path": normalized, "message": f"Unable to inspect file: {exc}"}
1294
+ if size > max_bytes:
1295
+ return {
1296
+ "state": "oversized",
1297
+ "path": normalized,
1298
+ "name": target.name,
1299
+ "size": size,
1300
+ "message": f"File preview is limited to {max_bytes} bytes; this file is {size} bytes.",
1301
+ }
1302
+ try:
1303
+ data = target.read_bytes()
1304
+ except OSError as exc:
1305
+ return {"state": "error", "path": normalized, "message": f"Unable to read file preview: {exc}"}
1306
+ content_type = mimetypes.guess_type(target.name)[0] or ""
1307
+ if content_type.startswith("image/"):
1308
+ return {
1309
+ "state": "image",
1310
+ "path": normalized,
1311
+ "name": target.name,
1312
+ "size": size,
1313
+ "contentType": content_type,
1314
+ "message": "Image preview is available from the workspace file endpoint.",
1315
+ }
1316
+ if b"\x00" in data:
1317
+ return {
1318
+ "state": "unsupported",
1319
+ "path": normalized,
1320
+ "name": target.name,
1321
+ "size": size,
1322
+ "message": "Binary or unsupported file content cannot be previewed.",
1323
+ }
1324
+ try:
1325
+ content = data.decode("utf-8")
1326
+ except UnicodeDecodeError:
1327
+ return {
1328
+ "state": "unsupported",
1329
+ "path": normalized,
1330
+ "name": target.name,
1331
+ "size": size,
1332
+ "message": "Binary or unsupported file encoding cannot be previewed.",
1333
+ }
1334
+ content = content.replace("\r\n", "\n").replace("\r", "\n")
1335
+ truncated = len(content) > max_chars
1336
+ if truncated:
1337
+ content = content[:max_chars]
1338
+ return {
1339
+ "state": "ok",
1340
+ "path": normalized,
1341
+ "name": target.name,
1342
+ "kind": "file",
1343
+ "size": size,
1344
+ "contentType": content_type or "text/plain",
1345
+ "content": content,
1346
+ "truncated": truncated,
1347
+ "logicsType": _logics_doc_type(normalized),
1348
+ "message": "",
1349
+ }
1350
+
1351
+
1159
1352
  def _current_git_ci_context(repo_root: Path, *, runner: Any | None = None) -> dict[str, str]:
1160
1353
  context = {"branch": "", "headSha": "", "subject": "", "author": ""}
1161
1354
  commands = {
@@ -1195,28 +1388,21 @@ def _is_active_ci_status(run: dict[str, Any]) -> bool:
1195
1388
 
1196
1389
 
1197
1390
  def _select_github_actions_run(runs: list[dict[str, Any]], head_sha: str) -> tuple[dict[str, Any], str]:
1198
- head_runs = [run for run in runs if head_sha and str(run.get("head_sha") or "") == head_sha]
1391
+ ci_runs = [run for run in runs if str(run.get("name") or "").strip().lower() == "ci"]
1392
+ candidate_runs = ci_runs or runs
1393
+ head_runs = [run for run in candidate_runs if head_sha and str(run.get("head_sha") or "") == head_sha]
1199
1394
  active_head_run = next((run for run in head_runs if _is_active_ci_status(run)), None)
1200
1395
  if active_head_run is not None:
1201
1396
  return active_head_run, "head-active"
1202
- failing_head_run = next((run for run in head_runs if _ci_badge_state(str(run.get("status") or ""), str(run.get("conclusion") or "")) == "failing"), None)
1203
- if failing_head_run is not None:
1204
- return failing_head_run, "head-failing"
1205
- cancelled_head_run = next((run for run in head_runs if _ci_badge_state(str(run.get("status") or ""), str(run.get("conclusion") or "")) == "cancelled"), None)
1206
- if cancelled_head_run is not None:
1207
- return cancelled_head_run, "head-cancelled"
1208
- unknown_head_run = next((run for run in head_runs if _ci_badge_state(str(run.get("status") or ""), str(run.get("conclusion") or "")) == "unknown"), None)
1209
- if unknown_head_run is not None:
1210
- return unknown_head_run, "head-unknown"
1211
1397
  if head_runs:
1398
+ head_state = _ci_badge_state(str(head_runs[0].get("status") or ""), str(head_runs[0].get("conclusion") or ""))
1399
+ if head_state in {"failing", "cancelled", "unknown"}:
1400
+ return head_runs[0], f"head-{head_state}"
1212
1401
  return head_runs[0], "head"
1213
- active_branch_run = next((run for run in runs if _is_active_ci_status(run)), None)
1402
+ active_branch_run = next((run for run in candidate_runs if _is_active_ci_status(run)), None)
1214
1403
  if active_branch_run is not None:
1215
1404
  return active_branch_run, "branch-active"
1216
- failing_branch_run = next((run for run in runs if _ci_badge_state(str(run.get("status") or ""), str(run.get("conclusion") or "")) == "failing"), None)
1217
- if failing_branch_run is not None:
1218
- return failing_branch_run, "branch-failing"
1219
- return runs[0], "branch-latest"
1405
+ return candidate_runs[0], "branch-latest"
1220
1406
 
1221
1407
 
1222
1408
  def _parse_github_actions_run(run: dict[str, Any], *, match_source: str) -> dict[str, Any]:
@@ -1605,6 +1791,13 @@ def _cdx_mission_prompt(
1605
1791
  raise ValueError("Unknown CDX mission.")
1606
1792
 
1607
1793
 
1794
+ def _cdx_mission_timeout(strength: dict[str, Any], *, allow_file_writes: bool = False, commit_at_end: bool = False) -> int:
1795
+ timeout = int(strength.get("timeout") or 180)
1796
+ if allow_file_writes or commit_at_end:
1797
+ return max(timeout, CDX_WRITABLE_MISSION_MIN_TIMEOUT_SECONDS)
1798
+ return timeout
1799
+
1800
+
1608
1801
  def _cdx_mission_command(
1609
1802
  repo_root: Path,
1610
1803
  mission_id: str,
@@ -1627,7 +1820,7 @@ def _cdx_mission_command(
1627
1820
  direct_fixes=mission_inputs.get("directFixes") == "true",
1628
1821
  commit_at_end=commit_at_end,
1629
1822
  )
1630
- timeout = int(strength.get("timeout") or 180)
1823
+ timeout = _cdx_mission_timeout(strength, allow_file_writes=allow_file_writes, commit_at_end=commit_at_end)
1631
1824
  reasoning_effort = str(strength.get("reasoningEffort") or "medium")
1632
1825
  power = str(strength.get("power") or "medium")
1633
1826
  permission = "workspace-write" if allow_file_writes else "read-only"
@@ -1868,6 +2061,7 @@ def cdx_mission_plan_payload(
1868
2061
  "requestedCommitAtEnd": requested_commit_at_end,
1869
2062
  "supportsFileWrites": supports_file_writes,
1870
2063
  "permission": permission,
2064
+ "timeoutSeconds": _cdx_mission_timeout(strength_def, allow_file_writes=allow_file_writes, commit_at_end=commit_at_end),
1871
2065
  "command": ["cdx", *command],
1872
2066
  "arguments": command,
1873
2067
  "warnings": warnings,
@@ -1895,9 +2089,10 @@ def cdx_mission_run_payload(
1895
2089
  if plan_payload.get("state") != "ok":
1896
2090
  return {"state": plan_payload.get("state") or "error", "message": plan_payload.get("message") or "Unable to plan CDX mission.", "plan": plan_payload.get("plan"), "run": None}
1897
2091
  plan = plan_payload["plan"]
1898
- timeout = int(plan["strength"].get("timeout") or 180)
2092
+ timeout = int(plan.get("timeoutSeconds") or plan["strength"].get("timeout") or 180)
2093
+ process_timeout = timeout + CDX_MISSION_PARENT_TIMEOUT_GRACE_SECONDS
1899
2094
  try:
1900
- result = _run_cdx_mission(repo_root, list(plan["arguments"]), timeout=timeout, runner=cdx_runner)
2095
+ result = _run_cdx_mission(repo_root, list(plan["arguments"]), timeout=process_timeout, runner=cdx_runner)
1901
2096
  except subprocess.TimeoutExpired:
1902
2097
  return {"state": "timeout", "message": "CDX mission timed out.", "plan": plan, "run": None}
1903
2098
  except (OSError, subprocess.SubprocessError) as exc:
@@ -2123,6 +2318,7 @@ class LogicsViewerServer(ThreadingHTTPServer):
2123
2318
  repo_root: Path,
2124
2319
  *,
2125
2320
  auto_refresh_interval_seconds: int = 15,
2321
+ auto_refresh_interval_forced: bool = False,
2126
2322
  ):
2127
2323
  self.launch_repo_root = repo_root.resolve()
2128
2324
  self.project_roots = discover_viewer_project_roots(self.launch_repo_root)
@@ -2130,6 +2326,7 @@ class LogicsViewerServer(ThreadingHTTPServer):
2130
2326
  self.active_project_id = _viewer_project_id(self.launch_repo_root)
2131
2327
  self.repo_root = self.launch_repo_root
2132
2328
  self.auto_refresh_interval_seconds = auto_refresh_interval_seconds
2329
+ self.auto_refresh_interval_forced = auto_refresh_interval_forced
2133
2330
  super().__init__(server_address, LogicsViewerRequestHandler)
2134
2331
 
2135
2332
  def project_registry_payload(self) -> list[dict[str, Any]]:
@@ -2140,6 +2337,7 @@ class LogicsViewerServer(ThreadingHTTPServer):
2140
2337
  self.repo_root,
2141
2338
  selected_id=selected_id,
2142
2339
  auto_refresh_interval_seconds=self.auto_refresh_interval_seconds,
2340
+ auto_refresh_interval_forced=self.auto_refresh_interval_forced,
2143
2341
  projects=self.project_registry_payload(),
2144
2342
  )
2145
2343
 
@@ -2266,6 +2464,32 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
2266
2464
  rel_path = params.get("path", [""])[0]
2267
2465
  self._send_json({"ok": True, "payload": git_file_preview_payload(self.server.repo_root, rel_path)})
2268
2466
  return
2467
+ if route == "/api/workspace-tree":
2468
+ rel_path = parse_qs(parsed.query).get("path", [""])[0]
2469
+ try:
2470
+ self._send_json({"ok": True, "payload": workspace_tree_payload(self.server.repo_root, rel_path)})
2471
+ except ValueError as exc:
2472
+ self._send_error_json(HTTPStatus.FORBIDDEN, str(exc))
2473
+ return
2474
+ if route == "/api/workspace-preview":
2475
+ rel_path = parse_qs(parsed.query).get("path", [""])[0]
2476
+ try:
2477
+ self._send_json({"ok": True, "payload": workspace_preview_payload(self.server.repo_root, rel_path)})
2478
+ except ValueError as exc:
2479
+ self._send_error_json(HTTPStatus.FORBIDDEN, str(exc))
2480
+ return
2481
+ if route == "/api/workspace-file":
2482
+ rel_path = parse_qs(parsed.query).get("path", [""])[0]
2483
+ try:
2484
+ payload = workspace_preview_payload(self.server.repo_root, rel_path)
2485
+ if payload.get("state") != "image":
2486
+ self._send_error_json(HTTPStatus.BAD_REQUEST, "Workspace file is not an image preview.")
2487
+ return
2488
+ _normalized, absolute = _resolve_workspace_path(self.server.repo_root, rel_path)
2489
+ self._serve_file(absolute)
2490
+ except (FileNotFoundError, ValueError) as exc:
2491
+ self._send_error_json(HTTPStatus.NOT_FOUND, str(exc))
2492
+ return
2269
2493
  self._send_error_json(HTTPStatus.NOT_FOUND, "Not found")
2270
2494
 
2271
2495
  def do_POST(self) -> None:
@@ -2394,11 +2618,13 @@ def create_viewer_server(
2394
2618
  port: int = 8765,
2395
2619
  *,
2396
2620
  auto_refresh_interval_seconds: int = 15,
2621
+ auto_refresh_interval_forced: bool = False,
2397
2622
  ) -> LogicsViewerServer:
2398
2623
  return LogicsViewerServer(
2399
2624
  (host, port),
2400
2625
  repo_root,
2401
2626
  auto_refresh_interval_seconds=auto_refresh_interval_seconds,
2627
+ auto_refresh_interval_forced=auto_refresh_interval_forced,
2402
2628
  )
2403
2629
 
2404
2630
 
@@ -2446,7 +2672,7 @@ def build_parser() -> argparse.ArgumentParser:
2446
2672
  parser.add_argument(
2447
2673
  "--refresh-interval",
2448
2674
  type=int,
2449
- default=15,
2675
+ default=None,
2450
2676
  help="Automatic refresh interval in seconds. Defaults to 15; positive intervals are allowed.",
2451
2677
  )
2452
2678
  parser.add_argument("--focus", help="Open the viewer focused on a workflow ref or repo-relative Logics Markdown path.")
@@ -2459,7 +2685,9 @@ def build_parser() -> argparse.ArgumentParser:
2459
2685
  def main(argv: list[str]) -> int:
2460
2686
  args = build_parser().parse_args(argv)
2461
2687
  repo_root = find_repo_root(Path.cwd())
2462
- if args.refresh_interval <= 0:
2688
+ refresh_interval_forced = args.refresh_interval is not None
2689
+ refresh_interval = args.refresh_interval if args.refresh_interval is not None else 15
2690
+ if refresh_interval <= 0:
2463
2691
  raise SystemExit("--refresh-interval must be a positive number of seconds.")
2464
2692
  if args.read and not args.focus:
2465
2693
  raise SystemExit("--read requires --focus.")
@@ -2471,7 +2699,8 @@ def main(argv: list[str]) -> int:
2471
2699
  repo_root,
2472
2700
  host=args.host,
2473
2701
  port=args.port,
2474
- auto_refresh_interval_seconds=args.refresh_interval,
2702
+ auto_refresh_interval_seconds=refresh_interval,
2703
+ auto_refresh_interval_forced=refresh_interval_forced,
2475
2704
  )
2476
2705
  host, port = server.server_address[:2]
2477
2706
  url = build_viewer_url(str(host), int(port), focus=focus, read=bool(args.read))
@@ -2483,7 +2712,7 @@ def main(argv: list[str]) -> int:
2483
2712
  focus=focus,
2484
2713
  network_url=network_url,
2485
2714
  bind_host=str(host),
2486
- auto_refresh_interval_seconds=args.refresh_interval,
2715
+ auto_refresh_interval_seconds=refresh_interval,
2487
2716
  ),
2488
2717
  flush=True,
2489
2718
  )
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@grifhinz/logics-manager",
3
3
  "displayName": "Logics Orchestrator",
4
4
  "description": "Visual orchestration for Logics workflows inside VS Code.",
5
- "version": "2.8.0",
5
+ "version": "2.8.1",
6
6
  "publisher": "cdx-logics",
7
7
  "icon": "clients/shared-web/media/icon.png",
8
8
  "repository": {
@@ -138,6 +138,7 @@
138
138
  "audit:logics": "node scripts/run-python.mjs -m logics_manager audit && node scripts/run-python.mjs -m logics_manager lint",
139
139
  "audit:logics:strict": "node scripts/run-python.mjs -m logics_manager audit --governance-profile strict && node scripts/run-python.mjs -m logics_manager lint --require-status",
140
140
  "audit:ci": "node scripts/check-npm-audit.mjs",
141
+ "docs:check": "node scripts/check-readme-badges.mjs",
141
142
  "logics:finish:task": "node scripts/run-python.mjs -m logics_manager flow finish task",
142
143
  "ci:fast": "npm run compile && npm run lint && npm run test:coverage && npm run test:smoke && npm run lint:logics && npm run package:ci",
143
144
  "ci:check": "node scripts/ci-check.mjs",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "logics-manager"
7
- version = "2.8.0"
7
+ version = "2.8.1"
8
8
  description = "Canonical Logics CLI"
9
9
  requires-python = ">=3.10"
10
10