tamarind-cli 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.
@@ -0,0 +1,311 @@
1
+ """Job lifecycle commands: submit/validate/batch/jobs/status/wait/results/logs/cancel/delete."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import uuid
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ import httpx
11
+ import typer
12
+
13
+ from ... import jobs as jobs_helpers
14
+ from ... import rest
15
+ from ...errors import NotFoundError, TamarindError, ValidationError
16
+ from .. import output
17
+ from ..inputs import resolve_job_input
18
+
19
+
20
+ def _gen_name(tool: str) -> str:
21
+ return f"{tool}-{uuid.uuid4().hex[:8]}"
22
+
23
+
24
+ def _message(resp: object) -> str:
25
+ """Best-effort human message from a response that may be a dict or a string."""
26
+ if isinstance(resp, dict):
27
+ return str(resp.get("message", resp))
28
+ return str(resp)
29
+
30
+
31
+ def _download(url: str, dest: Path) -> int:
32
+ """Stream a presigned URL to ``dest``. Returns bytes written."""
33
+ dest.parent.mkdir(parents=True, exist_ok=True)
34
+ total = 0
35
+ with httpx.stream("GET", url, follow_redirects=True, timeout=300.0) as resp:
36
+ resp.raise_for_status()
37
+ with dest.open("wb") as fh:
38
+ for chunk in resp.iter_bytes():
39
+ fh.write(chunk)
40
+ total += len(chunk)
41
+ return total
42
+
43
+
44
+ def register(app: typer.Typer) -> None:
45
+ @app.command()
46
+ def validate(
47
+ ctx: typer.Context,
48
+ tool: str = typer.Argument(..., help="Tool name (e.g. 'boltz')."),
49
+ input: Optional[str] = typer.Option(None, "--input", "-i", help="Settings file (YAML/JSON), '-' for stdin, or @yaml://path."),
50
+ set_: list[str] = typer.Option([], "--set", help="Override a setting: key=value (repeatable)."),
51
+ name: Optional[str] = typer.Option(None, "--name", "-n", help="Job name (default: auto)."),
52
+ ) -> None:
53
+ """Validate a job's settings without submitting (catches errors early)."""
54
+ state = ctx.obj
55
+ job = resolve_job_input(input, set_)
56
+ job_name = name or job.job_name or _gen_name(tool)
57
+ with state.rest_client() as client:
58
+ result = rest.validate_job(
59
+ client, job_name=job_name, job_type=job.job_type or tool, settings=job.settings
60
+ )
61
+ valid = bool(result.get("valid"))
62
+ human = "valid ✓" if valid else f"invalid ✗ {result.get('error', '')}"
63
+ output.emit(result, state.output, human=human)
64
+ if not valid:
65
+ raise typer.Exit(ValidationError.exit_code)
66
+
67
+ @app.command()
68
+ def submit(
69
+ ctx: typer.Context,
70
+ tool: str = typer.Argument(..., help="Tool name (e.g. 'boltz'). See `tamarind tools`."),
71
+ input: Optional[str] = typer.Option(None, "--input", "-i", help="Settings file (YAML/JSON), '-' for stdin, or @yaml://path."),
72
+ set_: list[str] = typer.Option([], "--set", help="Override a setting: key=value (repeatable)."),
73
+ name: Optional[str] = typer.Option(None, "--name", "-n", help="Job name (default: auto-generated)."),
74
+ skip_validate: bool = typer.Option(False, "--skip-validate", help="Skip the pre-submit validate-job check."),
75
+ wait: bool = typer.Option(False, "--wait", help="Block until the job reaches a terminal state."),
76
+ poll_interval: float = typer.Option(10.0, "--poll-interval", help="Seconds between polls when --wait."),
77
+ download: Optional[Path] = typer.Option(None, "--download", help="With --wait, download results to this directory."),
78
+ ) -> None:
79
+ """Submit a single job. Validates first unless --skip-validate."""
80
+ state = ctx.obj
81
+ job = resolve_job_input(input, set_)
82
+ job_type = job.job_type or tool
83
+ job_name = name or job.job_name or _gen_name(tool)
84
+
85
+ with state.rest_client() as client:
86
+ if not skip_validate:
87
+ v = rest.validate_job(client, job_name=job_name, job_type=job_type, settings=job.settings)
88
+ if not v.get("valid"):
89
+ raise ValidationError(f"Settings invalid: {v.get('error', 'unknown error')}", detail=v)
90
+ # NB: submit the user's original settings, NOT validate-job's
91
+ # `normalized` output — the normalizer injects backend-internal
92
+ # fields (e.g. submit_method, msa) that submit-job rejects.
93
+
94
+ output.info(f"Submitting {job_type} job '{job_name}'…", state.output)
95
+ submit_resp = rest.submit_job(client, job_name=job_name, job_type=job_type, settings=job.settings)
96
+
97
+ result = {"jobName": job_name, "type": job_type, "submit": submit_resp}
98
+
99
+ if wait:
100
+ output.info("Waiting for completion…", state.output)
101
+ final = jobs_helpers.wait_for_job(
102
+ client,
103
+ job_name,
104
+ poll_interval=poll_interval,
105
+ on_poll=lambda j: output.info(f" status: {jobs_helpers.job_status(j)}", state.output),
106
+ )
107
+ result["final"] = final
108
+ status = jobs_helpers.job_status(final)
109
+ if download and jobs_helpers.is_success(status):
110
+ url = rest.get_result(client, job_name=job_name)
111
+ dest = download / f"{job_name}.zip"
112
+ written = _download(url, dest)
113
+ result["download"] = {"path": str(dest), "bytes": written}
114
+ output.info(f" downloaded {written} bytes → {dest}", state.output)
115
+
116
+ human = f"submitted: {job_name}" + (
117
+ f" ({jobs_helpers.job_status(result['final'])})" if "final" in result else ""
118
+ )
119
+ output.emit(result, state.output, human=human)
120
+
121
+ @app.command()
122
+ def batch(
123
+ ctx: typer.Context,
124
+ tool: str = typer.Argument(..., help="Tool name applied to every job in the batch."),
125
+ input: str = typer.Option(..., "--input", "-i", help="YAML/JSON list of per-job settings, or a {batchName,type,settings[],jobNames} object."),
126
+ name: Optional[str] = typer.Option(None, "--name", "-n", help="Batch name (default: auto)."),
127
+ max_runtime: Optional[int] = typer.Option(None, "--max-runtime", help="Max runtime seconds per job."),
128
+ ) -> None:
129
+ """Submit many jobs as one batch (preferred over looping submit)."""
130
+ state = ctx.obj
131
+ from ..inputs import _load_text, _parse_document # internal reuse
132
+
133
+ doc = _parse_document(_load_text(input))
134
+ batch_name = name or _gen_name(tool)
135
+ job_type = tool
136
+ job_names = None
137
+ if isinstance(doc, list):
138
+ settings_list = doc
139
+ elif isinstance(doc, dict) and isinstance(doc.get("settings"), list):
140
+ settings_list = doc["settings"]
141
+ batch_name = name or doc.get("batchName") or batch_name
142
+ job_type = doc.get("type") or tool
143
+ job_names = doc.get("jobNames")
144
+ else:
145
+ raise TamarindError("Batch --input must be a list of settings or a {settings:[...]} object.")
146
+
147
+ with state.rest_client() as client:
148
+ resp = rest.submit_batch(
149
+ client,
150
+ batch_name=batch_name,
151
+ job_type=job_type,
152
+ settings=settings_list,
153
+ job_names=job_names,
154
+ max_runtime_seconds=max_runtime,
155
+ )
156
+ result = {"batchName": batch_name, "type": job_type, "count": len(settings_list), "submit": resp}
157
+ output.emit(result, state.output, human=f"submitted batch '{batch_name}' ({len(settings_list)} jobs)")
158
+
159
+ @app.command()
160
+ def jobs(
161
+ ctx: typer.Context,
162
+ status: Optional[str] = typer.Option(None, "--status", help="Filter by status (client-side)."),
163
+ batch: Optional[str] = typer.Option(None, "--batch", help="Only jobs in this batch."),
164
+ limit: int = typer.Option(50, "--limit", help="Max jobs to return."),
165
+ organization: bool = typer.Option(False, "--organization", help="All jobs across your org."),
166
+ include_subjobs: bool = typer.Option(False, "--include-subjobs", help="Include batch subjobs."),
167
+ email: Optional[str] = typer.Option(None, "--email", help="Jobs for another org member."),
168
+ ) -> None:
169
+ """List your jobs."""
170
+ state = ctx.obj
171
+ with state.rest_client() as client:
172
+ resp = rest.get_jobs(
173
+ client,
174
+ batch=batch,
175
+ limit=limit,
176
+ organization=organization,
177
+ include_subjobs=include_subjobs,
178
+ job_email=email,
179
+ )
180
+ job_list = resp.get("jobs", resp if isinstance(resp, list) else [])
181
+ if status:
182
+ job_list = [j for j in job_list if (jobs_helpers.job_status(j) or "").lower() == status.lower()]
183
+ rows = [
184
+ {
185
+ "JobName": jobs_helpers.job_name(j),
186
+ "Type": j.get("Type"),
187
+ "JobStatus": jobs_helpers.job_status(j),
188
+ "Created": j.get("Created"),
189
+ "Score": j.get("Score"),
190
+ }
191
+ for j in job_list
192
+ ]
193
+ out = {"jobs": job_list, "count": len(job_list)}
194
+ if isinstance(resp, dict) and resp.get("statuses"):
195
+ out["statuses"] = resp["statuses"]
196
+ output.emit(out, state.output, human=output.render_table(rows, ["JobName", "Type", "JobStatus", "Created", "Score"]))
197
+
198
+ @app.command()
199
+ def status(
200
+ ctx: typer.Context,
201
+ job_name: str = typer.Argument(..., help="Job name."),
202
+ ) -> None:
203
+ """Show one job's current status and metadata."""
204
+ state = ctx.obj
205
+ with state.rest_client() as client:
206
+ job = jobs_helpers.fetch_job(client, job_name)
207
+ output.emit(job, state.output, human=f"{job_name}: {jobs_helpers.job_status(job)}")
208
+
209
+ @app.command()
210
+ def wait(
211
+ ctx: typer.Context,
212
+ job_name: str = typer.Argument(..., help="Job name."),
213
+ poll_interval: float = typer.Option(10.0, "--poll-interval", help="Seconds between polls."),
214
+ timeout: Optional[float] = typer.Option(None, "--timeout", help="Give up after N seconds."),
215
+ ) -> None:
216
+ """Block until a job reaches a terminal state."""
217
+ state = ctx.obj
218
+ with state.rest_client() as client:
219
+ final = jobs_helpers.wait_for_job(
220
+ client,
221
+ job_name,
222
+ poll_interval=poll_interval,
223
+ timeout=timeout,
224
+ on_poll=lambda j: output.info(f" status: {jobs_helpers.job_status(j)}", state.output),
225
+ )
226
+ output.emit(final, state.output, human=f"{job_name}: {jobs_helpers.job_status(final)}")
227
+
228
+ @app.command()
229
+ def results(
230
+ ctx: typer.Context,
231
+ job_name: str = typer.Argument(..., help="Job name."),
232
+ download: Optional[Path] = typer.Option(None, "--download", help="Download the results bundle to this directory."),
233
+ file: Optional[str] = typer.Option(None, "--file", help="A specific file within the results."),
234
+ pdbs_only: bool = typer.Option(False, "--pdbs-only", help="Only PDB outputs."),
235
+ wait: bool = typer.Option(False, "--wait", help="Wait for the job to finish first."),
236
+ poll_interval: float = typer.Option(10.0, "--poll-interval", help="Seconds between polls when --wait."),
237
+ ) -> None:
238
+ """Get a presigned results URL, or download the results bundle."""
239
+ state = ctx.obj
240
+ with state.rest_client() as client:
241
+ if wait:
242
+ output.info("Waiting for completion…", state.output)
243
+ jobs_helpers.wait_for_job(client, job_name, poll_interval=poll_interval)
244
+ url = rest.get_result(
245
+ client, job_name=job_name, file_name=file, pdbs_only=pdbs_only or None
246
+ )
247
+ if not isinstance(url, str):
248
+ # Defensive: some deployments may wrap the URL in an object.
249
+ url = url.get("url") if isinstance(url, dict) else str(url)
250
+ result = {"jobName": job_name, "url": url}
251
+ if download:
252
+ suffix = file or f"{job_name}.zip"
253
+ dest = download / Path(suffix).name
254
+ written = _download(url, dest)
255
+ result["download"] = {"path": str(dest), "bytes": written}
256
+ human = result.get("download", {}).get("path") if download else url
257
+ output.emit(result, state.output, human=str(human))
258
+
259
+ @app.command()
260
+ def logs(
261
+ ctx: typer.Context,
262
+ job_name: str = typer.Argument(..., help="Job name."),
263
+ max_lines: int = typer.Option(500, "--max-lines", help="Tail at most this many lines."),
264
+ ) -> None:
265
+ """Fetch a job's run logs (served by the catalog/gateway service)."""
266
+ state = ctx.obj
267
+ with state.catalog_client() as client:
268
+ resp = client.get_json(f"catalog/jobs/{job_name}/logs", params={"maxLines": max_lines})
269
+ if isinstance(resp, dict):
270
+ # getJobLogs returns {"log": "..."} on success, {"error": "..."} otherwise.
271
+ if resp.get("error"):
272
+ msg = str(resp["error"])
273
+ ml = msg.lower()
274
+ if "not found" in ml or "no such" in ml or "does not exist" in ml:
275
+ raise NotFoundError(msg)
276
+ raise TamarindError(msg)
277
+ text = resp.get("log") or resp.get("hint") or json.dumps(resp, indent=2)
278
+ else:
279
+ text = resp
280
+ output.emit(resp, state.output, human=str(text))
281
+
282
+ @app.command()
283
+ def cancel(
284
+ ctx: typer.Context,
285
+ job_name: Optional[str] = typer.Argument(None, help="Job name to cancel."),
286
+ batch: Optional[str] = typer.Option(None, "--batch", help="Cancel an entire batch/pipeline instead."),
287
+ ) -> None:
288
+ """Cancel a running/queued job, or an entire batch."""
289
+ state = ctx.obj
290
+ if not job_name and not batch:
291
+ raise TamarindError("Provide a job name or --batch <name>.")
292
+ with state.rest_client() as client:
293
+ if batch:
294
+ resp = rest.cancel_batch(client, batch_name=batch)
295
+ else:
296
+ resp = rest.cancel_job(client, job_name=job_name)
297
+ output.emit(resp, state.output, human=_message(resp))
298
+
299
+ @app.command()
300
+ def delete(
301
+ ctx: typer.Context,
302
+ job_name: str = typer.Argument(..., help="Job name to permanently delete."),
303
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
304
+ ) -> None:
305
+ """Permanently delete a job (and its subjobs, for batches)."""
306
+ state = ctx.obj
307
+ if not yes and not state.output.json:
308
+ typer.confirm(f"Permanently delete job '{job_name}'?", abort=True)
309
+ with state.rest_client() as client:
310
+ resp = rest.delete_job(client, job_name=job_name)
311
+ output.emit(resp, state.output, human=_message(resp))
tamarind/cli/inputs.py ADDED
@@ -0,0 +1,115 @@
1
+ """Resolve job inputs from files, stdin, or inline ``--set`` overrides.
2
+
3
+ A job's ``settings`` can come from:
4
+
5
+ - ``--input job.yaml`` (YAML or JSON, by content) — the file holds the
6
+ ``settings`` object (the same shape as a schema's ``exampleJob.settings``),
7
+ or a full ``{jobName, type, settings}`` envelope.
8
+ - ``--input -`` to read that document from stdin.
9
+ - ``@yaml://./job.yaml`` / ``@json://./job.json`` reference syntax, matching the
10
+ convention other agent CLIs use.
11
+ - ``--set key=value`` (repeatable) to set/override individual settings inline;
12
+ the value is parsed as a YAML scalar (so ``--set numSamples=5`` is an int).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import sys
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ import yaml
24
+
25
+ from ..errors import ValidationError
26
+
27
+
28
+ @dataclass
29
+ class JobInput:
30
+ settings: dict[str, Any]
31
+ job_type: str | None = None
32
+ job_name: str | None = None
33
+
34
+
35
+ def _load_text(source: str) -> str:
36
+ """Read raw text from a path, stdin (``-``), or an ``@scheme://path`` ref."""
37
+ if source == "-":
38
+ return sys.stdin.read()
39
+ if source.startswith("@"):
40
+ # @yaml://./file.yaml or @json://./file.json or @./file
41
+ body = source[1:]
42
+ for scheme in ("yaml://", "json://", "file://"):
43
+ if body.startswith(scheme):
44
+ body = body[len(scheme) :]
45
+ break
46
+ source = body
47
+ path = Path(source).expanduser()
48
+ if not path.exists():
49
+ raise ValidationError(f"Input file not found: {path}")
50
+ return path.read_text()
51
+
52
+
53
+ def _parse_document(text: str) -> Any:
54
+ text = text.strip()
55
+ if not text:
56
+ return {}
57
+ # YAML is a superset of JSON, so safe_load handles both.
58
+ try:
59
+ return yaml.safe_load(text)
60
+ except yaml.YAMLError as exc:
61
+ raise ValidationError(f"Could not parse input as YAML/JSON: {exc}") from exc
62
+
63
+
64
+ def _coerce_scalar(raw: str) -> Any:
65
+ try:
66
+ return yaml.safe_load(raw)
67
+ except yaml.YAMLError:
68
+ return raw
69
+
70
+
71
+ def _apply_sets(settings: dict[str, Any], pairs: list[str]) -> None:
72
+ for pair in pairs:
73
+ if "=" not in pair:
74
+ raise ValidationError(f"--set expects key=value, got: {pair!r}")
75
+ key, raw = pair.split("=", 1)
76
+ settings[key.strip()] = _coerce_scalar(raw)
77
+
78
+
79
+ def _looks_like_envelope(doc: dict[str, Any]) -> bool:
80
+ return "settings" in doc and ("type" in doc or "jobName" in doc)
81
+
82
+
83
+ def resolve_job_input(
84
+ input_source: str | None,
85
+ set_pairs: list[str] | None,
86
+ ) -> JobInput:
87
+ """Build a :class:`JobInput` from ``--input`` and ``--set`` options."""
88
+ settings: dict[str, Any] = {}
89
+ job_type: str | None = None
90
+ job_name: str | None = None
91
+
92
+ if input_source:
93
+ doc = _parse_document(_load_text(input_source))
94
+ if doc is None:
95
+ doc = {}
96
+ if not isinstance(doc, dict):
97
+ raise ValidationError(
98
+ "Input must be a mapping (the job settings, or a "
99
+ "{jobName, type, settings} object)."
100
+ )
101
+ if _looks_like_envelope(doc):
102
+ settings = dict(doc.get("settings") or {})
103
+ job_type = doc.get("type")
104
+ job_name = doc.get("jobName")
105
+ else:
106
+ settings = dict(doc)
107
+
108
+ if set_pairs:
109
+ _apply_sets(settings, set_pairs)
110
+
111
+ return JobInput(settings=settings, job_type=job_type, job_name=job_name)
112
+
113
+
114
+ def dump_settings(settings: dict[str, Any]) -> str:
115
+ return json.dumps(settings, indent=2, default=str)
tamarind/cli/main.py ADDED
@@ -0,0 +1,122 @@
1
+ """``tamarind`` command-line entry point.
2
+
3
+ Layout: a global callback resolves config (key, endpoints, profile, output
4
+ mode) onto ``ctx.obj``; each command builds a short-lived HTTP client from it.
5
+ All Tamarind errors propagate to :func:`run`, which prints them and exits with
6
+ the error's stable exit code (see :mod:`tamarind.errors`).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from typing import Optional
13
+
14
+ import typer
15
+
16
+ from .. import __version__
17
+ from ..config import Config, load_config
18
+ from ..errors import TamarindError
19
+ from ..http import HTTPClient
20
+ from . import output
21
+ from .output import OutputMode
22
+ from .commands import auth as auth_cmds
23
+ from .commands import catalog as catalog_cmds
24
+ from .commands import files as files_cmds
25
+ from .commands import jobs as jobs_cmds
26
+
27
+
28
+ @dataclass
29
+ class State:
30
+ """Per-invocation state stored on the Typer context."""
31
+
32
+ output: OutputMode
33
+ _kwargs: dict
34
+
35
+ def config(self) -> Config:
36
+ return load_config(**self._kwargs)
37
+
38
+ def rest_client(self) -> HTTPClient:
39
+ cfg = self.config()
40
+ return HTTPClient(cfg.api_base, cfg.api_key)
41
+
42
+ def catalog_client(self) -> HTTPClient:
43
+ cfg = self.config()
44
+ return HTTPClient(cfg.catalog_base, cfg.api_key)
45
+
46
+
47
+ app = typer.Typer(
48
+ name="tamarind",
49
+ help=(
50
+ "Tamarind Bio CLI — discover tools, submit and monitor protein/molecule "
51
+ "jobs, and download results.\n\n"
52
+ "Auth: export TAMARIND_API_KEY, or run `tamarind auth login`.\n"
53
+ "Agents: pass --json (the default when stdout is not a terminal)."
54
+ ),
55
+ no_args_is_help=True,
56
+ add_completion=False,
57
+ )
58
+
59
+
60
+ def _version_callback(value: bool) -> None:
61
+ if value:
62
+ typer.echo(f"tamarind {__version__}")
63
+ raise typer.Exit()
64
+
65
+
66
+ @app.callback()
67
+ def main(
68
+ ctx: typer.Context,
69
+ api_key: Optional[str] = typer.Option(
70
+ None, "--api-key", envvar="TAMARIND_API_KEY", help="API key (overrides env/profile).", show_default=False
71
+ ),
72
+ api_base: Optional[str] = typer.Option(
73
+ None, "--api-base", envvar="TAMARIND_API_BASE", help="Job API base URL.", show_default=False
74
+ ),
75
+ catalog_base: Optional[str] = typer.Option(
76
+ None, "--catalog-base", envvar="TAMARIND_CATALOG_BASE", help="Catalog (discovery) base URL.", show_default=False
77
+ ),
78
+ profile: Optional[str] = typer.Option(
79
+ None, "--profile", envvar="TAMARIND_PROFILE", help="Named profile in ~/.tamarind/config.json.", show_default=False
80
+ ),
81
+ json_output: Optional[bool] = typer.Option(
82
+ None, "--json/--no-json", help="Machine JSON output. Defaults on when stdout isn't a TTY.", show_default=False
83
+ ),
84
+ quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress status lines."),
85
+ _version: Optional[bool] = typer.Option(
86
+ None, "--version", callback=_version_callback, is_eager=True, help="Show version and exit."
87
+ ),
88
+ ) -> None:
89
+ resolved_json = json_output if json_output is not None else (not output.is_tty())
90
+ ctx.obj = State(
91
+ output=OutputMode(json=resolved_json, quiet=quiet),
92
+ _kwargs={
93
+ "api_key": api_key,
94
+ "api_base": api_base,
95
+ "catalog_base": catalog_base,
96
+ "profile": profile,
97
+ },
98
+ )
99
+
100
+
101
+ # Sub-apps (grouped commands)
102
+ app.add_typer(auth_cmds.app, name="auth", help="Manage credentials.")
103
+ app.add_typer(files_cmds.app, name="files", help="List, upload, and delete workspace files.")
104
+
105
+ # Flat commands
106
+ catalog_cmds.register(app)
107
+ jobs_cmds.register(app)
108
+
109
+
110
+ def run() -> None:
111
+ """Console-script entry point with global error→exit-code mapping."""
112
+ try:
113
+ app()
114
+ except TamarindError as exc:
115
+ output.error(exc.message)
116
+ if exc.detail is not None:
117
+ typer.echo(typer.style(str(exc.detail), dim=True), err=True)
118
+ raise SystemExit(exc.exit_code)
119
+
120
+
121
+ if __name__ == "__main__":
122
+ run()
tamarind/cli/output.py ADDED
@@ -0,0 +1,68 @@
1
+ """Output helpers.
2
+
3
+ Every command can emit either machine JSON (``--json``, the default when stdout
4
+ is not a TTY) or a compact human rendering. Agents should pass ``--json`` (or
5
+ just rely on the non-TTY default) and parse stdout.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sys
12
+ from dataclasses import dataclass
13
+ from typing import Any, Sequence
14
+
15
+ import typer
16
+
17
+
18
+ @dataclass
19
+ class OutputMode:
20
+ json: bool
21
+ quiet: bool
22
+
23
+
24
+ def emit(obj: Any, mode: OutputMode, *, human: str | None = None) -> None:
25
+ """Emit a result. In JSON mode print ``obj`` as JSON; otherwise ``human``."""
26
+ if mode.json:
27
+ typer.echo(json.dumps(obj, indent=2, default=str))
28
+ elif human is not None:
29
+ typer.echo(human)
30
+ else:
31
+ typer.echo(json.dumps(obj, indent=2, default=str))
32
+
33
+
34
+ def info(message: str, mode: OutputMode) -> None:
35
+ """A status line for humans; suppressed in JSON/quiet mode (goes to stderr)."""
36
+ if mode.json or mode.quiet:
37
+ return
38
+ typer.secho(message, err=True, fg=typer.colors.BRIGHT_BLACK)
39
+
40
+
41
+ def error(message: str) -> None:
42
+ typer.secho(f"error: {message}", err=True, fg=typer.colors.RED)
43
+
44
+
45
+ def render_table(rows: Sequence[dict[str, Any]], columns: Sequence[str]) -> str:
46
+ """Render a fixed-width text table. ``columns`` are the dict keys to show."""
47
+ if not rows:
48
+ return "(none)"
49
+ widths = {c: len(c) for c in columns}
50
+ str_rows: list[dict[str, str]] = []
51
+ for r in rows:
52
+ sr = {}
53
+ for c in columns:
54
+ val = r.get(c)
55
+ text = "" if val is None else str(val)
56
+ sr[c] = text
57
+ widths[c] = max(widths[c], len(text))
58
+ str_rows.append(sr)
59
+ header = " ".join(c.ljust(widths[c]) for c in columns)
60
+ sep = " ".join("-" * widths[c] for c in columns)
61
+ lines = [header, sep]
62
+ for sr in str_rows:
63
+ lines.append(" ".join(sr[c].ljust(widths[c]) for c in columns))
64
+ return "\n".join(lines)
65
+
66
+
67
+ def is_tty() -> bool:
68
+ return sys.stdout.isatty()