egeo 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
egeo/__init__.py ADDED
@@ -0,0 +1,35 @@
1
+ """E-GEO runtime adapter layer and standalone ``egeo`` CLI.
2
+
3
+ This package decouples the four GEO agents (Analyzer, Ranker, Rewriter, Indexer)
4
+ from any single host runtime. The agent *contract* is defined once in
5
+ :mod:`egeo.agents`; concrete runtimes plug in implementations via
6
+ :mod:`egeo.runtimes`. The :mod:`egeo.cli` module exposes a runtime-agnostic
7
+ ``egeo`` command (``optimize``, ``evaluate``, ``optimize-prompts``, ``runtimes``).
8
+
9
+ The Ranker and Rewriter agents intentionally *reuse* the existing
10
+ ``geo_eval``/``llm_client`` modules at the repository root rather than
11
+ duplicating their logic. To make those root-level modules importable regardless
12
+ of the current working directory (e.g. ``python -m egeo`` from any folder), this
13
+ module bootstraps the repository root onto ``sys.path``.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ __version__ = "2.0.0"
21
+
22
+ # Repository root = parent of this package directory. Adding it to sys.path lets
23
+ # ``import geo_eval`` / ``import llm_client`` succeed even when the CLI is run
24
+ # from outside the repo root or installed in editable mode.
25
+ _REPO_ROOT = Path(__file__).resolve().parent.parent
26
+ if str(_REPO_ROOT) not in sys.path:
27
+ sys.path.insert(0, str(_REPO_ROOT))
28
+
29
+
30
+ def repo_root() -> Path:
31
+ """Return the repository root that ships ``geo_eval``/``prompts``/``geo-output``."""
32
+ return _REPO_ROOT
33
+
34
+
35
+ __all__ = ["__version__", "repo_root"]
egeo/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Enable ``python -m egeo`` to invoke the CLI."""
2
+ from __future__ import annotations
3
+
4
+ from .cli import main
5
+
6
+ if __name__ == "__main__":
7
+ raise SystemExit(main())
egeo/agents.py ADDED
@@ -0,0 +1,322 @@
1
+ """Runtime-agnostic abstractions for the four GEO agents.
2
+
3
+ The four agents mirror the Claude Code definitions in ``.claude/agents/`` but are
4
+ expressed here as plain Python objects so *any* runtime can construct and run
5
+ them:
6
+
7
+ - :class:`Analyzer` — deterministic, offline GEO-signal scorer (no LLM).
8
+ - :class:`Ranker` — thin wrapper over ``geo_eval._rank_candidates``.
9
+ - :class:`Rewriter` — thin wrapper over ``geo_eval._rewrite_description``.
10
+ - :class:`Indexer` — deterministic JSON-LD template filler (no LLM).
11
+
12
+ The Ranker and Rewriter deliberately delegate to ``geo_eval`` so there is a
13
+ single source of truth for ranking/rewriting logic and prompts. Analyzer and
14
+ Indexer are deterministic and offline, so the full pipeline runs without an API
15
+ key (and therefore under ``GEO_EVAL_MOCK=1`` in CI).
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import re
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+ from typing import Any, Dict, List, Optional, Sequence
24
+
25
+ # Reuse the harness rather than re-implementing it. The package __init__ has
26
+ # already placed the repo root on sys.path so these imports resolve.
27
+ import geo_eval
28
+ from geo_eval import Candidate
29
+
30
+ __all__ = [
31
+ "GEO_FEATURES",
32
+ "AnalysisResult",
33
+ "RankResult",
34
+ "RewriteResult",
35
+ "SchemaResult",
36
+ "Analyzer",
37
+ "Ranker",
38
+ "Rewriter",
39
+ "Indexer",
40
+ ]
41
+
42
+ # The 10 universal GEO features (see openspec/project.md and AGENTS.md).
43
+ GEO_FEATURES: List[str] = [
44
+ "ranking_emphasis",
45
+ "user_intent",
46
+ "competitive_diff",
47
+ "social_proof",
48
+ "narrative",
49
+ "authority",
50
+ "usps",
51
+ "urgency",
52
+ "scannable",
53
+ "factual",
54
+ ]
55
+
56
+ # Human-readable remediation copy used when a feature scores low.
57
+ _RECOMMENDATIONS: Dict[str, str] = {
58
+ "ranking_emphasis": "Frame the content as a top/leading choice (e.g. 'best for ...').",
59
+ "user_intent": "Open by directly answering the user's likely question or use case.",
60
+ "competitive_diff": "Call out concrete advantages over typical alternatives (no competitor names needed).",
61
+ "social_proof": "Add trust signals: customer counts, ratings, or testimonials (never fabricated).",
62
+ "narrative": "Tighten the flow into a persuasive, benefit-led story.",
63
+ "authority": "Add expert, confident phrasing and verifiable credentials or specifics.",
64
+ "usps": "Make the unique selling points explicit and scannable as bullets.",
65
+ "urgency": "Add a genuine urgency or scarcity signal where appropriate.",
66
+ "scannable": "Add headings, bullet lists, and short paragraphs for easy parsing.",
67
+ "factual": "Keep concrete, verifiable facts (numbers, units) and avoid vague hype.",
68
+ }
69
+
70
+
71
+ @dataclass
72
+ class AnalysisResult:
73
+ """Output of :class:`Analyzer` — GEO-signal scores and gap analysis."""
74
+
75
+ title: str
76
+ content_length: int
77
+ scores: Dict[str, int]
78
+ total_score: int
79
+ gaps: List[Dict[str, str]] = field(default_factory=list)
80
+ strengths: List[str] = field(default_factory=list)
81
+ priority_actions: List[str] = field(default_factory=list)
82
+
83
+ def to_dict(self) -> Dict[str, Any]:
84
+ return {
85
+ "title": self.title,
86
+ "content_length": self.content_length,
87
+ "scores": self.scores,
88
+ "total_score": self.total_score,
89
+ "gaps": self.gaps,
90
+ "strengths": self.strengths,
91
+ "priority_actions": self.priority_actions,
92
+ }
93
+
94
+
95
+ @dataclass
96
+ class RankResult:
97
+ """Output of :class:`Ranker` — an ordering of candidate ids."""
98
+
99
+ query: str
100
+ ordered_ids: List[str]
101
+
102
+ def position_of(self, candidate_id: str) -> int:
103
+ """1-based position of ``candidate_id`` in the ordering."""
104
+ return self.ordered_ids.index(candidate_id) + 1
105
+
106
+
107
+ @dataclass
108
+ class RewriteResult:
109
+ """Output of :class:`Rewriter` — the optimized description."""
110
+
111
+ title: str
112
+ original_description: str
113
+ rewritten_description: str
114
+
115
+
116
+ @dataclass
117
+ class SchemaResult:
118
+ """Output of :class:`Indexer` — a JSON-LD document."""
119
+
120
+ schema_type: str
121
+ json_ld: Dict[str, Any]
122
+
123
+ def to_json(self, *, indent: int = 2) -> str:
124
+ return json.dumps(self.json_ld, indent=indent, ensure_ascii=False)
125
+
126
+
127
+ # --------------------------------------------------------------------------- #
128
+ # Analyzer (deterministic, offline)
129
+ # --------------------------------------------------------------------------- #
130
+
131
+
132
+ def _strip_frontmatter(text: str) -> str:
133
+ """Remove a leading YAML/TOML frontmatter block if present."""
134
+ m = re.match(r"^---\s*\n.*?\n---\s*(?:\n|$)", text, re.DOTALL)
135
+ if m:
136
+ return text[m.end():]
137
+ m = re.match(r"^\+\+\+\s*\n.*?\n\+\+\+\s*(?:\n|$)", text, re.DOTALL)
138
+ if m:
139
+ return text[m.end():]
140
+ return text
141
+
142
+
143
+ def _clamp(value: int, low: int = 0, high: int = 10) -> int:
144
+ return max(low, min(high, value))
145
+
146
+
147
+ class Analyzer:
148
+ """Score content against the 10 GEO features with a transparent heuristic.
149
+
150
+ This is an *offline proxy* for the Claude ``geo-analyzer`` agent: it never
151
+ calls an LLM, so it is deterministic and runs with no API key. It looks for
152
+ the same signals the analyzer prompt describes (superlatives, intent, social
153
+ proof, structure, etc.).
154
+ """
155
+
156
+ _RANKING_WORDS = ("best", "top", "#1", "number one", "leading", "premier", "ultimate", "trusted", "award")
157
+ _INTENT_WORDS = ("best for", "ideal for", "designed for", "perfect for", "how to", "what is", "use case", "for businesses", "for teams")
158
+ _DIFF_WORDS = ("unlike", "compared to", "vs ", "versus", "alternative", "unique", "differentiat", "better than", "outperform")
159
+ _PROOF_WORDS = ("customer", "users", "rated", "rating", "review", "testimonial", "trusted by", "case study", "%", "stars")
160
+ _AUTHORITY_WORDS = ("expert", "certified", "proven", "industry", "research", "years", "award-winning", "official", "data shows")
161
+ _URGENCY_WORDS = ("now", "today", "limited", "offer", "hurry", "deadline", "while supplies", "act fast", "don't miss", "ends ")
162
+ _NARRATIVE_WORDS = ("because", "so that", "imagine", "whether", "that's why", "which means", "helps you", "you can")
163
+
164
+ def analyze(self, content: str, *, title: Optional[str] = None) -> AnalysisResult:
165
+ body = _strip_frontmatter(content)
166
+ lower = body.lower()
167
+ lines = [ln for ln in body.splitlines()]
168
+ non_empty = [ln for ln in lines if ln.strip()]
169
+ words = re.findall(r"[A-Za-z0-9#%']+", lower)
170
+ word_count = len(words)
171
+
172
+ if not title:
173
+ heading = next((ln for ln in lines if ln.strip().startswith("#")), "")
174
+ title = heading.lstrip("#").strip() or (non_empty[0].strip() if non_empty else "Untitled")
175
+
176
+ def keyword_score(keywords: Sequence[str], cap: int = 3) -> int:
177
+ hits = sum(lower.count(k) for k in keywords)
178
+ return _clamp(min(hits, cap) * (10 // cap) + (2 if hits else 0))
179
+
180
+ bullets = sum(1 for ln in lines if ln.strip().startswith(("-", "*", "•")) or re.match(r"^\s*\d+\.", ln))
181
+ headings = sum(1 for ln in lines if ln.strip().startswith("#"))
182
+ numbers = len(re.findall(r"\b\d[\d,\.]*\b", body))
183
+
184
+ scores: Dict[str, int] = {
185
+ "ranking_emphasis": keyword_score(self._RANKING_WORDS),
186
+ "user_intent": keyword_score(self._INTENT_WORDS),
187
+ "competitive_diff": keyword_score(self._DIFF_WORDS),
188
+ "social_proof": _clamp(keyword_score(self._PROOF_WORDS) + (2 if numbers >= 2 else 0)),
189
+ "narrative": _clamp(keyword_score(self._NARRATIVE_WORDS) + (2 if word_count > 80 else 0)),
190
+ "authority": keyword_score(self._AUTHORITY_WORDS),
191
+ "usps": _clamp((6 if bullets >= 3 else bullets * 2) + (2 if "feature" in lower or "benefit" in lower else 0)),
192
+ "urgency": keyword_score(self._URGENCY_WORDS),
193
+ "scannable": _clamp((4 if headings >= 1 else 0) + (4 if bullets >= 2 else bullets * 2) + (2 if len(non_empty) >= 4 else 0)),
194
+ "factual": _clamp(3 + min(numbers, 5) + (2 if numbers >= 3 else 0)),
195
+ }
196
+
197
+ total = sum(scores.values())
198
+ gaps = [
199
+ {
200
+ "feature": feat,
201
+ "current": f"Low signal (score {scores[feat]}/10)",
202
+ "recommendation": _RECOMMENDATIONS[feat],
203
+ }
204
+ for feat in GEO_FEATURES
205
+ if scores[feat] < 5
206
+ ]
207
+ strengths = [feat for feat in GEO_FEATURES if scores[feat] >= 7]
208
+ priority = sorted(GEO_FEATURES, key=lambda f: scores[f])[:3]
209
+ priority_actions = [_RECOMMENDATIONS[f] for f in priority]
210
+
211
+ return AnalysisResult(
212
+ title=title,
213
+ content_length=len(body),
214
+ scores=scores,
215
+ total_score=total,
216
+ gaps=gaps,
217
+ strengths=strengths,
218
+ priority_actions=priority_actions,
219
+ )
220
+
221
+
222
+ # --------------------------------------------------------------------------- #
223
+ # Ranker / Rewriter (reuse geo_eval; the only LLM-backed agents)
224
+ # --------------------------------------------------------------------------- #
225
+
226
+
227
+ class Ranker:
228
+ """Simulate AI-engine ranking by delegating to ``geo_eval._rank_candidates``."""
229
+
230
+ def __init__(self, *, client: Any, model: str, prompts_dir: Path, temperature: float = 0.0):
231
+ self._client = client
232
+ self._model = model
233
+ self._system = geo_eval._read_text(prompts_dir / "ranker_system.txt")
234
+ self._user = geo_eval._read_text(prompts_dir / "ranker_user.txt")
235
+ self._temperature = temperature
236
+
237
+ def rank(self, query: str, candidates: Sequence[Candidate]) -> RankResult:
238
+ ordered = geo_eval._rank_candidates(
239
+ client=self._client,
240
+ model=self._model,
241
+ system_prompt=self._system,
242
+ user_template=self._user,
243
+ query=query,
244
+ candidates=candidates,
245
+ temperature=self._temperature,
246
+ )
247
+ return RankResult(query=query, ordered_ids=list(ordered))
248
+
249
+
250
+ class Rewriter:
251
+ """Optimize a description by delegating to ``geo_eval._rewrite_description``."""
252
+
253
+ def __init__(self, *, client: Any, model: str, prompts_dir: Path, temperature: float = 0.0):
254
+ self._client = client
255
+ self._model = model
256
+ self._system = geo_eval._read_text(prompts_dir / "rewriter_system.txt")
257
+ self._user = geo_eval._read_text(prompts_dir / "rewriter_user.txt")
258
+ self._temperature = temperature
259
+
260
+ def rewrite(self, title: str, description: str) -> RewriteResult:
261
+ rewritten = geo_eval._rewrite_description(
262
+ client=self._client,
263
+ model=self._model,
264
+ system_prompt=self._system,
265
+ user_template=self._user,
266
+ title=title,
267
+ description=description,
268
+ temperature=self._temperature,
269
+ )
270
+ return RewriteResult(
271
+ title=title,
272
+ original_description=description,
273
+ rewritten_description=rewritten,
274
+ )
275
+
276
+
277
+ # --------------------------------------------------------------------------- #
278
+ # Indexer (deterministic, offline)
279
+ # --------------------------------------------------------------------------- #
280
+
281
+
282
+ class Indexer:
283
+ """Generate JSON-LD schema by filling a template shipped in ``geo-output/schema``.
284
+
285
+ This mirrors the Claude ``geo-indexer`` agent's job but is deterministic and
286
+ offline. Known ``name``/``description`` fields are filled; remaining
287
+ ``[FILL: ...]`` placeholders are left intact so users complete them. The
288
+ output still passes ``scripts/validate_jsonld.py``.
289
+ """
290
+
291
+ # Map a logical content type to a shipped template file.
292
+ _TEMPLATE_BY_TYPE = {
293
+ "Organization": "Organization.json",
294
+ "Product": "Product.json",
295
+ "Service": "Service.json",
296
+ "Article": "Article.json",
297
+ "FAQPage": "FAQPage.json",
298
+ }
299
+
300
+ def __init__(self, *, schema_dir: Path):
301
+ self._schema_dir = schema_dir
302
+
303
+ def available_types(self) -> List[str]:
304
+ return [t for t, f in self._TEMPLATE_BY_TYPE.items() if (self._schema_dir / f).exists()]
305
+
306
+ def generate_schema(self, *, schema_type: str, name: str, description: str) -> SchemaResult:
307
+ filename = self._TEMPLATE_BY_TYPE.get(schema_type)
308
+ if not filename:
309
+ raise ValueError(
310
+ f"Unknown schema type '{schema_type}'. Known: {sorted(self._TEMPLATE_BY_TYPE)}"
311
+ )
312
+ template_path = self._schema_dir / filename
313
+ doc: Dict[str, Any] = json.loads(template_path.read_text(encoding="utf-8"))
314
+
315
+ # Fill the two fields we can confidently populate from the pipeline.
316
+ name_key = "headline" if schema_type == "Article" else "name"
317
+ if name_key in doc:
318
+ doc[name_key] = name
319
+ if "description" in doc:
320
+ doc["description"] = description
321
+
322
+ return SchemaResult(schema_type=schema_type, json_ld=doc)
egeo/cli.py ADDED
@@ -0,0 +1,228 @@
1
+ """The standalone ``egeo`` command-line interface.
2
+
3
+ Runtime-agnostic entry point for the GEO pipeline. Subcommands:
4
+
5
+ - ``optimize <file>`` analyze → rank → rewrite → index a local file.
6
+ - ``evaluate`` thin wrapper over ``geo_eval.evaluate``.
7
+ - ``optimize-prompts`` thin wrapper over ``geo_eval.optimize`` (meta-optimizer).
8
+ - ``runtimes`` list available runtimes and their status.
9
+ - ``loop`` loop mode: ``run``, ``collect``, ``doctor`` (see :mod:`egeo.loop`).
10
+
11
+ Everything honors ``GEO_EVAL_MOCK=1`` (via ``llm_client.get_client``), so the
12
+ CLI runs offline in CI without an API key.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import os
19
+ import sys
20
+ from pathlib import Path
21
+ from typing import List, Optional
22
+
23
+ from . import __version__, repo_root
24
+
25
+ _DEFAULT_PROMPTS = str(repo_root() / "prompts")
26
+
27
+
28
+ def _add_evaluate_parser(sub: argparse._SubParsersAction) -> None:
29
+ p = sub.add_parser("evaluate", help="Evaluate prompt quality on a dataset (wraps geo_eval.evaluate).")
30
+ p.add_argument("--dataset", required=True)
31
+ p.add_argument("--prompts", default=_DEFAULT_PROMPTS)
32
+ p.add_argument("--ranker-model", default=os.environ.get("RANKER_MODEL", "gpt-4o"))
33
+ p.add_argument("--rewriter-model", default=os.environ.get("REWRITER_MODEL", "gpt-4o"))
34
+ p.add_argument("--temperature", type=float, default=0.0)
35
+ p.add_argument("--seed", type=int, default=7)
36
+ p.add_argument("--limit", type=int, default=None)
37
+ p.add_argument("--verbose", action="store_true")
38
+
39
+
40
+ def _add_optimize_prompts_parser(sub: argparse._SubParsersAction) -> None:
41
+ p = sub.add_parser(
42
+ "optimize-prompts",
43
+ help="Meta-optimize the rewriter prompt (wraps geo_eval.optimize; non-destructive by default).",
44
+ )
45
+ p.add_argument("--train", required=True)
46
+ p.add_argument("--val", required=True)
47
+ p.add_argument("--prompts", default=_DEFAULT_PROMPTS)
48
+ p.add_argument("--ranker-model", default=os.environ.get("RANKER_MODEL", "gpt-4o"))
49
+ p.add_argument("--rewriter-model", default=os.environ.get("REWRITER_MODEL", "gpt-4o"))
50
+ p.add_argument("--meta-model", default=os.environ.get("META_MODEL", "gpt-4o"))
51
+ p.add_argument("--temperature", type=float, default=0.0)
52
+ p.add_argument("--seed", type=int, default=7)
53
+ p.add_argument("--iters", type=int, default=5)
54
+ p.add_argument(
55
+ "--apply",
56
+ action="store_true",
57
+ help="Overwrite the working rewriter prompt in place (default: write *.candidate.txt).",
58
+ )
59
+
60
+
61
+ def _add_optimize_parser(sub: argparse._SubParsersAction) -> None:
62
+ p = sub.add_parser("optimize", help="Run the GEO pipeline on a local content file.")
63
+ p.add_argument("input", help="Path to a local content file (Markdown/text).")
64
+ p.add_argument("--out-dir", default="geo-output", help="Output directory (default: geo-output).")
65
+ p.add_argument("--query", default=None, help="Search query to rank against (default: derived from the title).")
66
+ p.add_argument(
67
+ "--schema-type",
68
+ default="Article",
69
+ choices=["Organization", "Product", "Service", "Article", "FAQPage"],
70
+ help="JSON-LD schema template to emit (default: Article).",
71
+ )
72
+ p.add_argument("--runtime", default="python", help="Runtime to use (default: python).")
73
+ p.add_argument("--ranker-model", default=os.environ.get("RANKER_MODEL", "gpt-4o"))
74
+ p.add_argument("--rewriter-model", default=os.environ.get("REWRITER_MODEL", "gpt-4o"))
75
+ p.add_argument("--temperature", type=float, default=0.0)
76
+ p.add_argument("--json", action="store_true", help="Print only the machine-readable JSON summary.")
77
+
78
+
79
+ def _cmd_loop(args: argparse.Namespace) -> int:
80
+ from . import loop
81
+
82
+ return loop.main(args)
83
+
84
+
85
+ def _add_runtimes_parser(sub: argparse._SubParsersAction) -> None:
86
+ p = sub.add_parser("runtimes", help="List available runtimes and their status.")
87
+ p.add_argument("--json", action="store_true", help="Print machine-readable JSON.")
88
+
89
+
90
+ def _cmd_evaluate(args: argparse.Namespace) -> int:
91
+ import geo_eval
92
+
93
+ summary = geo_eval.evaluate(
94
+ dataset_path=Path(args.dataset),
95
+ prompts_dir=Path(args.prompts),
96
+ ranker_model=args.ranker_model,
97
+ rewriter_model=args.rewriter_model,
98
+ temperature=args.temperature,
99
+ seed=args.seed,
100
+ limit=args.limit,
101
+ verbose=args.verbose,
102
+ )
103
+ print(json.dumps(summary, indent=2, sort_keys=True))
104
+ return 0
105
+
106
+
107
+ def _cmd_optimize_prompts(args: argparse.Namespace) -> int:
108
+ import geo_eval
109
+
110
+ res = geo_eval.optimize(
111
+ train_path=Path(args.train),
112
+ val_path=Path(args.val),
113
+ prompts_dir=Path(args.prompts),
114
+ ranker_model=args.ranker_model,
115
+ rewriter_model=args.rewriter_model,
116
+ meta_model=args.meta_model,
117
+ temperature=args.temperature,
118
+ seed=args.seed,
119
+ iters=args.iters,
120
+ apply=args.apply,
121
+ )
122
+ print(json.dumps(res, indent=2, sort_keys=True))
123
+ return 0
124
+
125
+
126
+ def _cmd_optimize(args: argparse.Namespace) -> int:
127
+ from .pipeline import optimize_content
128
+ from .runtimes import get_runtime
129
+
130
+ input_path = Path(args.input)
131
+ if not input_path.is_file():
132
+ print(f"ERROR: input file not found: {input_path}", file=sys.stderr)
133
+ return 1
134
+
135
+ content = input_path.read_text(encoding="utf-8")
136
+ runtime = get_runtime(
137
+ args.runtime,
138
+ ranker_model=args.ranker_model,
139
+ rewriter_model=args.rewriter_model,
140
+ temperature=args.temperature,
141
+ )
142
+ if not runtime.executes_in_process:
143
+ print(
144
+ f"ERROR: runtime '{runtime.name}' does not execute in-process. "
145
+ f"Use --runtime python, or run the pipeline inside Claude Code.",
146
+ file=sys.stderr,
147
+ )
148
+ return 2
149
+
150
+ result = optimize_content(
151
+ runtime=runtime,
152
+ content=content,
153
+ source=str(input_path),
154
+ output_dir=Path(args.out_dir),
155
+ query=args.query,
156
+ schema_type=args.schema_type,
157
+ )
158
+
159
+ if args.json:
160
+ print(json.dumps(result.summary(), indent=2, sort_keys=True))
161
+ return 0
162
+
163
+ delta = result.rank_improvement
164
+ delta_str = f"+{delta}" if delta > 0 else str(delta)
165
+ print(f"✓ Optimized: {result.title}")
166
+ print(f" Runtime: {result.runtime}")
167
+ print(f" GEO score: {result.analysis.total_score}/100")
168
+ print(f" Rank: #{result.rank_before} → #{result.rank_after} ({delta_str})")
169
+ print(f" Output dir: {result.output_dir}")
170
+ for path in result.written_files:
171
+ print(f" - {path}")
172
+ return 0
173
+
174
+
175
+ def _cmd_runtimes(args: argparse.Namespace) -> int:
176
+ from .runtimes import runtime_status
177
+
178
+ infos = runtime_status()
179
+ if args.json:
180
+ print(json.dumps([info.__dict__ for info in infos], indent=2, sort_keys=True))
181
+ return 0
182
+
183
+ print("Supported runtimes:\n")
184
+ for info in infos:
185
+ status = "available" if info.available else "unavailable"
186
+ mode = "in-process" if info.executes_in_process else "host-executed"
187
+ aliases = f" (aliases: {', '.join(info.aliases)})" if info.aliases else ""
188
+ print(f"• {info.name}{aliases}")
189
+ print(f" status: {status} | mode: {mode}")
190
+ print(f" {info.description}")
191
+ return 0
192
+
193
+
194
+ def build_parser() -> argparse.ArgumentParser:
195
+ parser = argparse.ArgumentParser(
196
+ prog="egeo",
197
+ description="E-GEO: runtime-agnostic Generative Engine Optimization CLI.",
198
+ )
199
+ parser.add_argument("--version", action="version", version=f"egeo {__version__}")
200
+ sub = parser.add_subparsers(dest="cmd", required=True)
201
+ _add_optimize_parser(sub)
202
+ _add_evaluate_parser(sub)
203
+ _add_optimize_prompts_parser(sub)
204
+ _add_runtimes_parser(sub)
205
+ from . import loop
206
+
207
+ loop.add_parser(sub)
208
+ return parser
209
+
210
+
211
+ _DISPATCH = {
212
+ "optimize": _cmd_optimize,
213
+ "evaluate": _cmd_evaluate,
214
+ "optimize-prompts": _cmd_optimize_prompts,
215
+ "runtimes": _cmd_runtimes,
216
+ "loop": _cmd_loop,
217
+ }
218
+
219
+
220
+ def main(argv: Optional[List[str]] = None) -> int:
221
+ parser = build_parser()
222
+ args = parser.parse_args(argv)
223
+ handler = _DISPATCH[args.cmd]
224
+ return handler(args)
225
+
226
+
227
+ if __name__ == "__main__":
228
+ raise SystemExit(main())