dromad 0.1.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.
dromad/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ """Dromad: GEO research tools for coding agents.
2
+
3
+ This distribution is the client half of Dromad: the ``dromad`` command, the
4
+ HTTP client under it, and the pydantic schemas in :mod:`dromad.schemas` that
5
+ are the contract with the hosted service. The service imports those schemas;
6
+ nothing here imports the service.
7
+ """
8
+
9
+ from importlib.metadata import PackageNotFoundError, version
10
+
11
+ try:
12
+ __version__ = version("dromad")
13
+ except PackageNotFoundError: # a source tree on sys.path without an install
14
+ __version__ = "0.0.0+unknown"
dromad/cli/__init__.py ADDED
File without changes
dromad/cli/analyses.py ADDED
@@ -0,0 +1,62 @@
1
+ """``dromad analyses list`` and ``dromad analyses show``."""
2
+
3
+ import typer
4
+
5
+ from dromad import config
6
+ from dromad.cli.citations import render_citations
7
+ from dromad.cli.fanout import render_fanout
8
+ from dromad.cli.output import emit_json, errors_reported, table
9
+ from dromad.client import Client
10
+ from dromad.schemas.analyses import CitationAnalysis
11
+ from dromad.schemas.ids import IdKind, is_id
12
+
13
+ app = typer.Typer(help="Inspect analyses: results derived from runs.", no_args_is_help=True)
14
+
15
+ JSON_OPTION = typer.Option(False, "--json", help="Print one JSON document to stdout.")
16
+ API_URL_OPTION = typer.Option(None, "--api-url", help="Dromad server.")
17
+
18
+
19
+ @app.command("list")
20
+ def list_analyses(
21
+ kind: str | None = typer.Option(None, "--kind", help="Only one kind: fanout or citations."),
22
+ limit: int = typer.Option(20, "--limit", min=1, max=200),
23
+ api_url: str | None = API_URL_OPTION,
24
+ as_json: bool = JSON_OPTION,
25
+ ) -> None:
26
+ """Your analyses, newest first."""
27
+ with errors_reported(as_json=as_json), Client.from_settings(config.resolve(api_url)) as client:
28
+ listing = client.list_analyses(kind=kind, limit=limit)
29
+ if as_json:
30
+ emit_json(listing)
31
+ return
32
+ if not listing.items:
33
+ typer.echo(
34
+ 'No analyses yet. Generate your first questions:\n\n dromad fanout "AI search API"'
35
+ )
36
+ return
37
+ rows = [
38
+ [a.id, a.kind, a.status, a.run_count, a.created_at.strftime("%Y-%m-%d %H:%M"), a.subject]
39
+ for a in listing.items
40
+ ]
41
+ typer.echo(table(rows, ["ID", "KIND", "STATUS", "RUNS", "CREATED", "SUBJECT"]))
42
+
43
+
44
+ @app.command("show")
45
+ def show(
46
+ analysis_id: str = typer.Argument(..., help="An analysis ID (fa_… or ca_…)."),
47
+ api_url: str | None = API_URL_OPTION,
48
+ as_json: bool = JSON_OPTION,
49
+ ) -> None:
50
+ """One analysis, in full."""
51
+ if not is_id(analysis_id, IdKind.FANOUT_ANALYSIS, IdKind.CITATION_ANALYSIS):
52
+ raise typer.BadParameter(
53
+ f"{analysis_id!r} is not an analysis ID (fa_… or ca_…)", param_hint="ANALYSIS_ID"
54
+ )
55
+ with errors_reported(as_json=as_json), Client.from_settings(config.resolve(api_url)) as client:
56
+ analysis = client.get_analysis(analysis_id)
57
+ if as_json:
58
+ emit_json(analysis)
59
+ elif isinstance(analysis, CitationAnalysis):
60
+ typer.echo(render_citations(analysis))
61
+ else:
62
+ typer.echo(render_fanout(analysis))
dromad/cli/audit.py ADDED
@@ -0,0 +1,167 @@
1
+ """``dromad audit`` — what one page looks like to a crawler, and what follows."""
2
+
3
+ import time
4
+
5
+ import typer
6
+
7
+ from dromad import config
8
+ from dromad.cli.exit_codes import ExitCode
9
+ from dromad.cli.output import emit_json, errors_reported, info
10
+ from dromad.client import Client
11
+ from dromad.client.errors import DromadError
12
+ from dromad.schemas.audit import AuditReport, AuditResult
13
+ from dromad.schemas.runs import TERMINAL_RUN_STATUSES, Run
14
+
15
+ STATUS_MARK = {"fail": "✗", "error": "!", "pass": "✓", "not_applicable": "–"}
16
+
17
+
18
+ def _line(label: str, value) -> str:
19
+ return f"{label:<12} {value}"
20
+
21
+
22
+ def render_audit(result: AuditResult, run_id: str) -> str:
23
+ fetch, content, meta, structure = (
24
+ result.fetch,
25
+ result.content,
26
+ result.metadata,
27
+ result.structure,
28
+ )
29
+ lines = [result.page.url]
30
+ if result.page.final_url != result.page.url:
31
+ lines.append(f" → {result.page.final_url}")
32
+ hops = len(fetch.redirect_chain)
33
+ parts = [f"{fetch.status} {fetch.http_status or ''}".strip(), fetch.content_type or "–"]
34
+ parts.append(f"{fetch.elapsed_ms} ms")
35
+ if hops:
36
+ parts.append(f"{hops} redirect{'s' if hops > 1 else ''}")
37
+ if fetch.robots_txt and fetch.robots_txt.allows_page is False:
38
+ parts.append("robots.txt disallows")
39
+ lines += ["", _line("Fetched", " · ".join(parts))]
40
+ if fetch.error:
41
+ lines.append(_line("", fetch.error))
42
+ if meta.title is not None or content.extraction_method:
43
+ lines += [
44
+ _line("Title", meta.title or "—"),
45
+ _line("Description", meta.description or "—"),
46
+ _line("Canonical", meta.canonical or "—"),
47
+ _line("Language", meta.lang or "—"),
48
+ ]
49
+ links = content.links
50
+ lines.append(
51
+ _line(
52
+ "Content",
53
+ f"{content.word_count:,} words · {content.code_blocks} code blocks · "
54
+ f"{content.tables} tables · {links.total} links ({links.internal} internal) · "
55
+ f"via {content.extraction_method}",
56
+ )
57
+ )
58
+ if content.likely_js_dependent:
59
+ lines.append(
60
+ _line("", "looks like a JavaScript shell (Dromad does not run JavaScript)")
61
+ )
62
+ skips = len(structure.level_skips)
63
+ lines.append(
64
+ _line(
65
+ "Outline",
66
+ # The outline is the main content's; whether the page has an h1
67
+ # at all is the missing_h1 finding, which looks at the whole page.
68
+ f"{len(structure.headings)} headings in the main content · "
69
+ f"{skips} level skip{'s' if skips != 1 else ''}",
70
+ )
71
+ )
72
+ if meta.structured_data_types or meta.structured_data_errors:
73
+ types = ", ".join(meta.structured_data_types) or "—"
74
+ errors = (
75
+ f" · {meta.structured_data_errors} unparseable"
76
+ if meta.structured_data_errors
77
+ else ""
78
+ )
79
+ lines.append(_line("Structured", types + errors))
80
+ if meta.dates.published or meta.dates.modified:
81
+ lines.append(
82
+ _line(
83
+ "Dates",
84
+ f"published {meta.dates.published or '—'} · modified {meta.dates.modified or '—'}",
85
+ )
86
+ )
87
+ if meta.llms_txt:
88
+ probes = " · ".join(
89
+ f"{p.url.split('://', 1)[-1]} "
90
+ + (f"found ({p.links} links)" if p.found else str(p.http_status or "no response"))
91
+ for p in meta.llms_txt
92
+ )
93
+ lines.append(_line("llms.txt", probes))
94
+
95
+ lines += ["", "FINDINGS"]
96
+ width = max(len(f.key) for f in result.findings) if result.findings else 0
97
+ for finding in result.findings:
98
+ mark = STATUS_MARK.get(finding.status, "?")
99
+ head = f" {mark} {finding.key:<{width}} {finding.status:<14} {finding.severity:<6} {finding.evidence_level}"
100
+ lines.append(head.rstrip())
101
+ for item in finding.items:
102
+ lines.append(f" {item.summary}")
103
+ if finding.status in ("not_applicable", "error") and finding.reason:
104
+ lines.append(f" {finding.reason}")
105
+ lines += ["", f"Run {run_id}"]
106
+ return "\n".join(lines)
107
+
108
+
109
+ def render_audit_run(run: Run) -> str:
110
+ if isinstance(run.result, AuditResult):
111
+ return render_audit(run.result, run.id)
112
+ if run.error:
113
+ return "\n".join(
114
+ [
115
+ f"{run.input.get('url', '')}",
116
+ f"✗ {run.error.code}",
117
+ run.error.message,
118
+ f"Retryable: {'yes' if run.error.retryable else 'no'}",
119
+ f"Run {run.id}",
120
+ ]
121
+ )
122
+ return f"{run.input.get('url', '')}\n… {run.status}\nRun {run.id}"
123
+
124
+
125
+ def wait_for_run(client: Client, run_id: str, *, timeout: float) -> Run:
126
+ deadline = time.monotonic() + timeout
127
+ while True:
128
+ remaining = deadline - time.monotonic()
129
+ run = client.get_run(run_id, wait=max(0, min(30, int(remaining))))
130
+ if run.status in TERMINAL_RUN_STATUSES:
131
+ return run
132
+ if time.monotonic() >= deadline:
133
+ raise DromadError(
134
+ "timeout",
135
+ f"Run {run_id} is still running after {timeout:g}s. It continues; "
136
+ f"check it with `dromad runs show {run_id}`.",
137
+ retryable=True,
138
+ )
139
+
140
+
141
+ def audit(
142
+ url: str = typer.Argument(..., help="The page to audit."),
143
+ wait: bool = typer.Option(True, "--wait/--no-wait", help="Wait for the result (default)."),
144
+ timeout: int = typer.Option(180, "--timeout", help="Seconds to wait."),
145
+ api_url: str | None = typer.Option(None, "--api-url", help="Dromad server."),
146
+ as_json: bool = typer.Option(False, "--json", help="Print one JSON document to stdout."),
147
+ ) -> None:
148
+ """Fetch one page as a crawler would and report its facts and findings.
149
+
150
+ Every finding says how strongly it is backed (evidence_level): an observed
151
+ fact, an established consequence, a suggestive signal, or an experimental
152
+ hypothesis. The result is a run (dr_…) that keeps the full captured page.
153
+ """
154
+ with errors_reported(as_json=as_json), Client.from_settings(config.resolve(api_url)) as client:
155
+ run = client.create_audit(url)
156
+ if wait:
157
+ info(f"Auditing {url} · run {run.id}")
158
+ run = wait_for_run(client, run.id, timeout=timeout)
159
+ if as_json:
160
+ if isinstance(run.result, AuditResult):
161
+ emit_json(AuditReport(run_id=run.id, **run.result.model_dump()))
162
+ else:
163
+ emit_json(run)
164
+ else:
165
+ typer.echo(render_audit_run(run))
166
+ if run.status == "failed":
167
+ raise typer.Exit(int(ExitCode.FAILURE))
dromad/cli/auth.py ADDED
@@ -0,0 +1,136 @@
1
+ """``dromad login``, ``dromad logout``, ``dromad whoami``."""
2
+
3
+ import os
4
+ import socket
5
+ import time
6
+ import webbrowser
7
+ from datetime import UTC, datetime
8
+
9
+ import typer
10
+
11
+ from dromad import config
12
+ from dromad.cli.output import emit_json, errors_reported, info
13
+ from dromad.client import Client
14
+ from dromad.client.errors import ApiError, ConnectionFailed, DromadError
15
+ from dromad.schemas.auth import CLIAuthStatus
16
+
17
+ # Indirection so tests can run the polling loop without waiting.
18
+ _sleep = time.sleep
19
+ _monotonic = time.monotonic
20
+
21
+ JSON_OPTION = typer.Option(False, "--json", help="Print one JSON document to stdout.")
22
+
23
+
24
+ def _default_client_name() -> str:
25
+ return f"dromad CLI on {socket.gethostname()}"
26
+
27
+
28
+ def login(
29
+ api_url: str | None = typer.Option(
30
+ None, "--api-url", help="Dromad server (default: DROMAD_API_URL, else dromad.dev)."
31
+ ),
32
+ name: str | None = typer.Option(
33
+ None, "--name", help="How this device appears under your account's sessions."
34
+ ),
35
+ no_browser: bool = typer.Option(
36
+ False, "--no-browser", help="Print the sign-in URL instead of opening it."
37
+ ),
38
+ as_json: bool = JSON_OPTION,
39
+ ) -> None:
40
+ """Sign in through the browser and store a CLI token for this device."""
41
+ with errors_reported(as_json=as_json):
42
+ settings = config.resolve(api_url)
43
+ with Client(settings.api_url, token=None) as client:
44
+ start = client.cli_auth_start(name or _default_client_name())
45
+ info("To sign in, open this URL in a browser:")
46
+ info(f"\n {start.verification_uri_complete}\n")
47
+ info(f"and check that it shows the code {start.user_code}\n")
48
+ if not no_browser:
49
+ try:
50
+ webbrowser.open(start.verification_uri_complete)
51
+ except webbrowser.Error:
52
+ pass
53
+ info("Waiting for approval…")
54
+
55
+ deadline = _monotonic() + start.expires_in
56
+ while True:
57
+ _sleep(start.interval)
58
+ poll = client.cli_auth_poll(start.device_code)
59
+ if poll.status == CLIAuthStatus.APPROVED and poll.token and poll.token_id:
60
+ break
61
+ if poll.status == CLIAuthStatus.DENIED:
62
+ raise DromadError("access_denied", "Sign-in was denied in the browser.")
63
+ if poll.status != CLIAuthStatus.PENDING or _monotonic() > deadline:
64
+ raise DromadError(
65
+ "expired_token",
66
+ "The sign-in request expired before it was approved. "
67
+ "Run `dromad login` again.",
68
+ )
69
+
70
+ path = config.save_credentials(
71
+ config.StoredCredentials(
72
+ api_url=settings.api_url,
73
+ token=poll.token,
74
+ token_id=poll.token_id,
75
+ email=poll.email or "",
76
+ created_at=datetime.now(UTC),
77
+ )
78
+ )
79
+ if as_json:
80
+ emit_json({"email": poll.email, "token_id": poll.token_id, "api_url": settings.api_url})
81
+ else:
82
+ info(f"Logged in as {poll.email}.")
83
+ info(f"Credentials saved to {path}")
84
+
85
+
86
+ def logout(as_json: bool = JSON_OPTION) -> None:
87
+ """Revoke this device's CLI token and delete the local credentials."""
88
+ with errors_reported(as_json=as_json):
89
+ stored = config.load_credentials()
90
+ revoked = False
91
+ if stored is not None:
92
+ try:
93
+ with Client(stored.api_url, stored.token) as client:
94
+ client.revoke_cli_token()
95
+ revoked = True
96
+ except ApiError as exc:
97
+ # Already revoked or expired server-side: nothing left to undo.
98
+ if exc.status != 401:
99
+ raise
100
+ except ConnectionFailed:
101
+ info(
102
+ f"Could not reach {stored.api_url} to revoke the token; "
103
+ "removing the local credentials anyway. Revoke it from "
104
+ "your account's sessions page."
105
+ )
106
+ config.delete_credentials()
107
+ env_key_set = bool(os.environ.get(config.ENV_API_KEY))
108
+ if as_json:
109
+ emit_json(
110
+ {
111
+ "logged_out": stored is not None,
112
+ "token_revoked": revoked,
113
+ "api_key_env_set": env_key_set,
114
+ }
115
+ )
116
+ return
117
+ info("Logged out." if stored is not None else "Not logged in.")
118
+ if env_key_set:
119
+ info(f"{config.ENV_API_KEY} is still set; unset it to stop using that key.")
120
+
121
+
122
+ def whoami(
123
+ api_url: str | None = typer.Option(None, "--api-url", help="Dromad server."),
124
+ as_json: bool = JSON_OPTION,
125
+ ) -> None:
126
+ """Show which account and credential the CLI is using."""
127
+ with errors_reported(as_json=as_json):
128
+ settings = config.resolve(api_url)
129
+ with Client.from_settings(settings) as client:
130
+ me = client.me()
131
+ if as_json:
132
+ emit_json(me)
133
+ return
134
+ typer.echo(me.email)
135
+ typer.echo(f"{me.credential.kind} {me.credential.id} ({me.credential.name})")
136
+ typer.echo(f"source {settings.source} · {settings.api_url}")
@@ -0,0 +1,133 @@
1
+ """``dromad citations`` — what a set of prompt runs cited, counted."""
2
+
3
+ import typer
4
+
5
+ from dromad import config
6
+ from dromad.cli.output import emit_json, errors_reported, table
7
+ from dromad.cli.render import short_url
8
+ from dromad.client import Client
9
+ from dromad.schemas.analyses import CitationAnalysis
10
+ from dromad.schemas.ids import IdKind, is_id
11
+
12
+
13
+ def _pct(numerator: int, denominator: int) -> str:
14
+ return f"{round(100 * numerator / denominator)}%" if denominator else "–"
15
+
16
+
17
+ def _n(count: int, noun: str) -> str:
18
+ return f"{count} {noun}{'' if count == 1 else 's'}"
19
+
20
+
21
+ def render_citations(analysis: CitationAnalysis, *, top: int = 10) -> str:
22
+ t = analysis.totals
23
+ lines = [
24
+ f"{t.runs} prompt runs · {t.prompts} prompts · {t.engines} engines",
25
+ f"{t.citations} citations · {t.unique_cited_urls} unique URLs · "
26
+ f"{t.unique_cited_domains} unique domains",
27
+ ]
28
+ unknowns = []
29
+ if t.retrieved_not_cited:
30
+ unknowns.append(f"{t.retrieved_not_cited} sources seen but not cited")
31
+ if t.citation_status_unknown:
32
+ unknowns.append(f"{t.citation_status_unknown} with citation status unknown")
33
+ if t.unresolved_sources:
34
+ unknowns.append(f"{t.unresolved_sources} unresolved redirects")
35
+ if unknowns:
36
+ lines.append(" · ".join(unknowns))
37
+
38
+ if analysis.domains:
39
+ rows = [
40
+ [d.domain, d.citations, d.pages, d.runs, d.prompts, ", ".join(d.engines)]
41
+ for d in analysis.domains[:top]
42
+ ]
43
+ lines += ["", table(rows, ["DOMAIN", "CITATIONS", "PAGES", "RUNS", "PROMPTS", "ENGINES"])]
44
+
45
+ if len(analysis.engines) > 1:
46
+ rows = [
47
+ [e.engine, e.runs, e.runs_with_citations, e.citations, e.unique_cited_domains]
48
+ for e in analysis.engines
49
+ ]
50
+ lines += ["", table(rows, ["ENGINE", "RUNS", "WITH CITATIONS", "CITATIONS", "DOMAINS"])]
51
+
52
+ if analysis.pages:
53
+ lines += ["", "TOP PAGES"]
54
+ for n, page in enumerate(analysis.pages[:top], 1):
55
+ lines.append(
56
+ f" {n:>2} {short_url(page.url, 70)} · {_n(page.citations, 'citation')} · "
57
+ f"{_n(page.runs, 'run')} · {', '.join(page.engines)}"
58
+ )
59
+
60
+ overlap = analysis.overlap
61
+ if overlap.engines > 1:
62
+ by = overlap.domains_by_engine_count
63
+ parts = [f"by {n}: {by[str(n)]}" for n in range(overlap.engines, 0, -1)]
64
+ lines += ["", "CROSS-ENGINE OVERLAP", " cited domains " + " · ".join(parts)]
65
+ if overlap.domains_cited_by_every_engine:
66
+ lines.append(
67
+ " cited by every engine: " + ", ".join(overlap.domains_cited_by_every_engine)
68
+ )
69
+
70
+ if analysis.textual_mentions:
71
+ lines += ["", "TEXTUAL MENTIONS (naive whole-word match in answer text)"]
72
+ for m in analysis.textual_mentions:
73
+ lines.append(
74
+ f" {m.term} {m.runs_mentioning} of {m.runs} runs · "
75
+ f"{m.prompts_mentioning} of {m.prompts} prompts · {m.occurrences} occurrences"
76
+ )
77
+
78
+ for c in analysis.domain_coverage:
79
+ engines = " · ".join(f"{e.engine} {e.cited_runs}/{e.runs}" for e in c.by_engine)
80
+ lines += [
81
+ "",
82
+ f"DOMAIN COVERAGE {c.domain}",
83
+ f" cited in {c.cited_runs} of {c.runs} runs ({_pct(c.cited_runs, c.runs)}) · "
84
+ f"{c.cited_prompts} of {c.prompts} prompts · {c.citations} citations",
85
+ f" by engine: {engines}",
86
+ ]
87
+ if c.unknown_runs:
88
+ lines.append(
89
+ f" {_n(c.unknown_runs, 'run')} did not expose citations; the rate is a floor"
90
+ )
91
+ if c.pages:
92
+ lines.append(f" pages cited: {', '.join(short_url(p, 60) for p in c.pages[:top])}")
93
+ if c.prompts_seen_not_cited:
94
+ lines.append(f" seen but not cited on {_n(len(c.prompts_seen_not_cited), 'prompt')}")
95
+ if c.prompts_absent:
96
+ lines.append(
97
+ f" absent from {_n(len(c.prompts_absent), 'prompt')}, e.g. “{c.prompts_absent[0]}”"
98
+ )
99
+
100
+ if analysis.excluded_runs:
101
+ lines += ["", f"Excluded {len(analysis.excluded_runs)} runs:"]
102
+ lines += [f" {e.run_id} {e.reason}" for e in analysis.excluded_runs]
103
+ lines += ["", f"Analysis {analysis.id} · {analysis.method}"]
104
+ return "\n".join(lines)
105
+
106
+
107
+ def citations(
108
+ ids: list[str] = typer.Argument(..., help="Run IDs (dr_…) and/or batch IDs (rq_…)."),
109
+ brand: list[str] = typer.Option(
110
+ [], "--brand", "-b", help="A term to count in answers (repeatable). Naive word match."
111
+ ),
112
+ domain: list[str] = typer.Option(
113
+ [], "--domain", "-d", help="A domain to report coverage for (repeatable)."
114
+ ),
115
+ top: int = typer.Option(10, "--top", min=1, help="Rows per table."),
116
+ api_url: str | None = typer.Option(None, "--api-url", help="Dromad server."),
117
+ as_json: bool = typer.Option(False, "--json", help="Print one JSON document to stdout."),
118
+ ) -> None:
119
+ """Count what a set of prompt runs cited: domains, pages, engines, overlap.
120
+
121
+ Only citations an engine showed are counted; sources whose citation
122
+ status was not exposed are reported as unknown. The result is a durable
123
+ analysis (ca_…).
124
+ """
125
+ bad = [i for i in ids if not is_id(i, IdKind.RUN, IdKind.REQUEST_BATCH)]
126
+ if bad:
127
+ raise typer.BadParameter(f"not a run or batch ID: {', '.join(bad)}", param_hint="IDS")
128
+ with errors_reported(as_json=as_json), Client.from_settings(config.resolve(api_url)) as client:
129
+ analysis = client.create_citation_analysis(ids, brands=brand, domains=domain)
130
+ if as_json:
131
+ emit_json(analysis)
132
+ else:
133
+ typer.echo(render_citations(analysis, top=top))
@@ -0,0 +1,14 @@
1
+ """Process exit codes. Stable: scripts and agents branch on them.
2
+
3
+ 0 the command did what it was asked
4
+ 1 the operation failed (the server refused it, an engine failed, no connection)
5
+ 2 the command could not be attempted (bad arguments, not logged in, bad config)
6
+ """
7
+
8
+ from enum import IntEnum
9
+
10
+
11
+ class ExitCode(IntEnum):
12
+ OK = 0
13
+ FAILURE = 1
14
+ USAGE = 2
dromad/cli/fanout.py ADDED
@@ -0,0 +1,89 @@
1
+ """``dromad fanout`` — turn a topic into the questions people ask about it."""
2
+
3
+ import time
4
+
5
+ import typer
6
+
7
+ from dromad import config
8
+ from dromad.cli.exit_codes import ExitCode
9
+ from dromad.cli.output import emit_json, errors_reported, info
10
+ from dromad.client import Client
11
+ from dromad.client.errors import DromadError
12
+ from dromad.schemas.analyses import TERMINAL_ANALYSIS_STATUSES, FanoutAnalysis
13
+
14
+
15
+ def render_fanout(analysis: FanoutAnalysis) -> str:
16
+ lines = [analysis.topic, ""]
17
+ if analysis.status == "failed" and analysis.error:
18
+ lines += [f"✗ {analysis.error.code}", analysis.error.message, ""]
19
+ elif analysis.status != "completed":
20
+ lines += [f"… {analysis.status}", ""]
21
+ else:
22
+ lines += [
23
+ f"Generated {len(analysis.questions)} questions across "
24
+ f"{len(analysis.clusters)} clusters.",
25
+ "",
26
+ ]
27
+ by_id = {q.id: q for q in analysis.questions}
28
+ width = len(str(len(analysis.questions)))
29
+ for cluster in analysis.clusters:
30
+ lines.append(f"{cluster.name.upper()} ({cluster.intent.replace('_', ' ')})")
31
+ lines.append("")
32
+ for question_id in cluster.question_ids:
33
+ question = by_id[question_id]
34
+ n = int(question_id.lstrip("q"))
35
+ tag = f" [{question.specificity}]" if question.specificity else ""
36
+ lines.append(f" {n:>{width}}. {question.text}{tag}")
37
+ lines.append("")
38
+ footer = [f"Analysis {analysis.id}"]
39
+ if analysis.provenance:
40
+ provenance = analysis.provenance
41
+ if provenance.model:
42
+ footer.append(provenance.model)
43
+ footer.append(
44
+ f"{provenance.strategy}/v{provenance.prompt_version} (prompt {provenance.prompt_sha[:10]})"
45
+ )
46
+ lines.append(" · ".join(footer))
47
+ return "\n".join(lines)
48
+
49
+
50
+ def wait_for_analysis(client: Client, analysis_id: str, *, timeout: float) -> FanoutAnalysis:
51
+ deadline = time.monotonic() + timeout
52
+ while True:
53
+ remaining = deadline - time.monotonic()
54
+ analysis = client.get_analysis(analysis_id, wait=max(0, min(30, int(remaining))))
55
+ if analysis.status in TERMINAL_ANALYSIS_STATUSES:
56
+ return analysis
57
+ if time.monotonic() >= deadline:
58
+ raise DromadError(
59
+ "timeout",
60
+ f"Analysis {analysis_id} is still running after {timeout:g}s. It continues; "
61
+ f"check it with `dromad analyses show {analysis_id}`.",
62
+ retryable=True,
63
+ )
64
+
65
+
66
+ def fanout(
67
+ topic: str = typer.Argument(..., help='The topic, e.g. "AI search API".'),
68
+ count: int = typer.Option(30, "--count", min=5, max=100, help="How many questions."),
69
+ wait: bool = typer.Option(True, "--wait/--no-wait", help="Wait for the questions (default)."),
70
+ timeout: int = typer.Option(300, "--timeout", help="Seconds to wait."),
71
+ api_url: str | None = typer.Option(None, "--api-url", help="Dromad server."),
72
+ as_json: bool = typer.Option(False, "--json", help="Print one JSON document to stdout."),
73
+ ) -> None:
74
+ """Generate the questions people ask AI assistants about a topic, clustered by intent.
75
+
76
+ The result is a durable analysis (fa_…) whose questions keep stable IDs
77
+ (q01…), with the full generation prompt recorded as provenance.
78
+ """
79
+ with errors_reported(as_json=as_json), Client.from_settings(config.resolve(api_url)) as client:
80
+ analysis = client.create_fanout(topic, count)
81
+ if wait:
82
+ info(f"Generating {count} questions · analysis {analysis.id}")
83
+ analysis = wait_for_analysis(client, analysis.id, timeout=timeout)
84
+ if as_json:
85
+ emit_json(analysis)
86
+ else:
87
+ typer.echo(render_fanout(analysis))
88
+ if analysis.status == "failed":
89
+ raise typer.Exit(int(ExitCode.FAILURE))
dromad/cli/main.py ADDED
@@ -0,0 +1,49 @@
1
+ """The ``dromad`` command."""
2
+
3
+ import typer
4
+
5
+ from dromad import __version__
6
+ from dromad.cli import analyses, audit, auth, citations, fanout, prompt, runs, share
7
+ from dromad.cli import open as open_command
8
+ from dromad.config import DEFAULT_API_URL
9
+
10
+ app = typer.Typer(
11
+ name="dromad",
12
+ help="GEO research tools for coding agents.",
13
+ epilog=f"Docs: {DEFAULT_API_URL}/docs/cli · for agents: {DEFAULT_API_URL}/docs/agents.md",
14
+ no_args_is_help=True,
15
+ add_completion=False,
16
+ )
17
+
18
+
19
+ def _print_version(value: bool) -> None:
20
+ if value:
21
+ typer.echo(f"dromad {__version__}")
22
+ raise typer.Exit()
23
+
24
+
25
+ @app.callback()
26
+ def main(
27
+ version: bool = typer.Option(
28
+ False,
29
+ "--version",
30
+ callback=_print_version,
31
+ is_eager=True,
32
+ help="Print the version and exit.",
33
+ ),
34
+ ) -> None:
35
+ """GEO research tools for coding agents."""
36
+
37
+
38
+ app.command()(auth.login)
39
+ app.command()(auth.logout)
40
+ app.command()(auth.whoami)
41
+ app.command()(fanout.fanout)
42
+ app.command()(prompt.prompt)
43
+ app.command()(citations.citations)
44
+ app.command()(audit.audit)
45
+ app.command(name="open")(open_command.open_page)
46
+ app.command()(share.share)
47
+ app.command()(share.unshare)
48
+ app.add_typer(runs.app, name="runs")
49
+ app.add_typer(analyses.app, name="analyses")