agent-tool-shared-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,120 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-tool-shared-cli
3
+ Version: 0.1.0
4
+ Summary: Shared chassis for agent-ready CLIs: the exit-code contract, JSON/table/markdown/CSV output with field projection, and keyring-backed credentials.
5
+ Author: Zierhut IT, Alexander Zierhut
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/alexander-zierhut/agent-tool-shared-cli
8
+ Project-URL: Repository, https://github.com/alexander-zierhut/agent-tool-shared-cli
9
+ Project-URL: Issues, https://github.com/alexander-zierhut/agent-tool-shared-cli/issues
10
+ Keywords: cli,command-line,ai-agent,llm,automation,json-output,keyring,exit-codes,agent-ready
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Software Development :: Libraries
17
+ Classifier: Topic :: Utilities
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ Requires-Dist: keyring>=25
21
+ Requires-Dist: rich>=13
22
+ Provides-Extra: test
23
+ Requires-Dist: pytest>=8; extra == "test"
24
+ Requires-Dist: pytest-timeout>=2.3; extra == "test"
25
+
26
+ # agent-tool-shared-cli
27
+
28
+ The shared chassis for the **`agent-tool-<x>-cli`** family — agent-ready command-line
29
+ tools that an LLM can drive with no prior knowledge of them.
30
+
31
+ Consumers: [`agent-tool-openproject-cli`](https://github.com/alexander-zierhut/agent-tool-openproject-cli),
32
+ `agent-tool-drone-cli` (in progress).
33
+
34
+ This repo also holds **[BLUEPRINT.md](BLUEPRINT.md)** — the engineering standard the
35
+ whole family is built to. Read that first if you are standing up a new tool.
36
+
37
+ ```bash
38
+ pip install agent-tool-shared-cli
39
+ ```
40
+
41
+ ## What's in it
42
+
43
+ This package is deliberately small. It holds **the agent contract** and the pure
44
+ utilities that implement it — the things that must be *identical* across every tool,
45
+ because an agent learns the contract once and applies it everywhere.
46
+
47
+ | Module | What |
48
+ |---|---|
49
+ | `agentcli.errors` | The exit-code taxonomy (`0/1/3/4/5/6/7/130`) + `DryRun` |
50
+ | `agentcli.output` | `Emitter`: json/table/markdown/csv, `--fields` projection, NDJSON streaming |
51
+ | `agentcli.credentials` | Keyring storage with a `0600` fallback and an env override |
52
+ | `agentcli.appspec` | `AppSpec` — the two strings that make all of the above tool-specific |
53
+
54
+ ```python
55
+ from agentcli import AppSpec, Credentials, Emitter, OutputFormat, NotFoundError
56
+
57
+ SPEC = AppSpec(name="drone-cli", env_prefix="DRONECLI")
58
+
59
+ SPEC.config_dir() # ~/.config/drone-cli (or $DRONECLI_CONFIG_DIR)
60
+ SPEC.env("TOKEN") # "DRONECLI_TOKEN"
61
+
62
+ Credentials(SPEC).get_token("default") # env > keyring > 0600 file
63
+ Emitter(OutputFormat.json, fields=["id", "status"]).emit(rows)
64
+ raise NotFoundError("no such build") # -> exit 5, JSON on stderr
65
+ ```
66
+
67
+ ## The exit-code contract
68
+
69
+ Published API. You may leave a code unallocated, or repurpose one deliberately.
70
+ **You may never renumber one** — agents branch on these, and they are documented in
71
+ three places per tool (README, the in-binary `guide`, and the Claude skill).
72
+
73
+ | Code | Meaning |
74
+ |---|---|
75
+ | 0 | success (including a successful `--dry-run`) |
76
+ | 1 | generic error |
77
+ | 2 | *reserved for Click/Typer usage errors — never allocate* |
78
+ | 3 | config error |
79
+ | 4 | auth error (401/403) |
80
+ | 5 | not found (404) |
81
+ | 6 | conflict (409 / optimistic locking) |
82
+ | 7 | validation error (422) — see `fieldErrors` |
83
+ | 8+ | per-tool, and only for a condition you have **observed** |
84
+ | 130 | SIGINT |
85
+
86
+ ## What is deliberately NOT here
87
+
88
+ `client.py`, `serialize.py`, `resolve.py` and the domain commands stay in each tool.
89
+ They *look* shareable and are not:
90
+
91
+ > OpenProject stops paginating on an authoritative `total` — "never stop on a short
92
+ > page". Drone has no total, and a short page **is** the terminator. The rule that
93
+ > inverts between tool #1 and tool #2 is exactly the rule you must not hoist.
94
+
95
+ Auth schemes, retry matrices and error-body shapes are the same story. Pulling them in
96
+ would make this a framework with a config object per tool, which is how shared-code
97
+ projects die.
98
+
99
+ **Share the contract, not the transport.**
100
+
101
+ ## Contributing
102
+
103
+ You need nothing but Python:
104
+
105
+ ```bash
106
+ pip install -e '.[test]'
107
+ pytest # 74 tests, no network, no services
108
+ ```
109
+
110
+ Changes here affect every downstream CLI. Two rules:
111
+
112
+ 1. **Never renumber an exit code, and never change an output shape** without treating
113
+ it as a breaking change — downstream agents depend on both.
114
+ 2. **Don't add a module because two tools happen to share it today.** Wait until a
115
+ third does, and until you can state the rule it obeys. `tests/test_contract.py`
116
+ is the tripwire: if a change makes you edit it, stop and think.
117
+
118
+ ## License
119
+
120
+ MIT
@@ -0,0 +1,9 @@
1
+ agentcli/__init__.py,sha256=tMQp6u5MjpCDHfQghecUYavmAYc2TOvwU3Z1l6Tzdp4,1959
2
+ agentcli/appspec.py,sha256=LatDORrPGDRa3MgV8hlTLw3eULqwjFkqp2s4l81dBZU,3883
3
+ agentcli/credentials.py,sha256=FOrnn4xtfXRT9rGHwfaJjAW0adayNNajZvz7pp8rmok,5229
4
+ agentcli/errors.py,sha256=AUDkETdEsa9DB6KnY8-pVASLv_31G2ENtspDquR21gg,2467
5
+ agentcli/output.py,sha256=jhPlzOdeioV5cHvfZ-JaJJP007MXwRxASmTkZScDRO4,10576
6
+ agent_tool_shared_cli-0.1.0.dist-info/METADATA,sha256=E6-gzK9e_eHjT8TFLq1_Y4sJrmMnqHQ0EV_2NbNQPYk,4759
7
+ agent_tool_shared_cli-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
8
+ agent_tool_shared_cli-0.1.0.dist-info/top_level.txt,sha256=z-NsMAZGHtIei-p-W9E2SURJ0eDY6L-t0epXY-eIkc8,9
9
+ agent_tool_shared_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ agentcli
agentcli/__init__.py ADDED
@@ -0,0 +1,58 @@
1
+ """agentcli — the shared chassis for `agent-tool-<x>-cli` tools.
2
+
3
+ This package is deliberately small. It holds **the agent contract** and the pure
4
+ utilities that implement it — the things that must be *identical* across every
5
+ tool we ship, because an agent learns the contract once and applies it everywhere:
6
+
7
+ * :mod:`agentcli.errors` — the exit-code taxonomy (0/1/3/4/5/6/7/130) + ``DryRun``
8
+ * :mod:`agentcli.output` — json/table/markdown/csv, ``--fields`` projection, NDJSON
9
+ * :mod:`agentcli.credentials` — keyring with a 0600 fallback and an env override
10
+ * :mod:`agentcli.appspec` — the two strings that make the above tool-specific
11
+
12
+ **What is deliberately NOT here**, and why it matters more than what is:
13
+
14
+ ``client.py``, ``serialize.py``, ``resolve.py`` and the domain command modules
15
+ stay in each tool. They *look* shareable and are not — OpenProject's pagination
16
+ stops on an authoritative ``total`` ("never stop on a short page"), while Drone
17
+ has no total and a short page IS the terminator. The rule that inverts between
18
+ tool #1 and tool #2 is exactly the rule you must not hoist. Auth schemes, retry
19
+ matrices and error-body shapes are the same story.
20
+
21
+ Share the contract, not the transport.
22
+
23
+ Pulling those in would turn this into a framework with a config object per tool,
24
+ which is how shared-code projects die.
25
+ """
26
+
27
+ from .appspec import AppSpec
28
+ from .credentials import Credentials
29
+ from .errors import (
30
+ ApiError,
31
+ AuthError,
32
+ ConfigError,
33
+ ConflictError,
34
+ DryRun,
35
+ NotFoundError,
36
+ OpError,
37
+ ValidationError,
38
+ )
39
+ from .output import Emitter, OutputFormat, print_error # NDJSON is Emitter.stream_json()
40
+
41
+ __version__ = "0.1.0"
42
+
43
+ __all__ = [
44
+ "AppSpec",
45
+ "Credentials",
46
+ "Emitter",
47
+ "OutputFormat",
48
+ "print_error",
49
+ "OpError",
50
+ "ApiError",
51
+ "AuthError",
52
+ "ConfigError",
53
+ "ConflictError",
54
+ "NotFoundError",
55
+ "ValidationError",
56
+ "DryRun",
57
+ "__version__",
58
+ ]
agentcli/appspec.py ADDED
@@ -0,0 +1,96 @@
1
+ """`AppSpec` — the two strings that make a shared chassis tool-specific.
2
+
3
+ Every tool in the `agent-tool-<x>-cli` family needs the same four things from
4
+ its identity:
5
+
6
+ * a **config directory** ``~/.config/<name>/``
7
+ * a **keyring service name** ``<name>``
8
+ * an **env-var namespace** ``<PREFIX>_TOKEN``, ``<PREFIX>_CONFIG_DIR``, …
9
+ * a **relocatable config dir**, so tests are hermetic
10
+
11
+ So that is all `AppSpec` carries. It is deliberately not a plugin system, a
12
+ registry or a settings framework — two strings and a few pure functions.
13
+
14
+ Why this exists at all: in `opcli` the config-dir logic was **duplicated** in
15
+ both ``config.py`` and ``credentials.py``. Two copies of "where do I live?" that
16
+ nothing forced to agree — relocate one and the token and the profile end up in
17
+ different directories. Here there is exactly one.
18
+
19
+ SPEC = AppSpec(name="op-cli", env_prefix="OPCLI")
20
+ SPEC.config_dir() # -> ~/.config/op-cli (or $OPCLI_CONFIG_DIR)
21
+ SPEC.env("TOKEN") # -> "OPCLI_TOKEN"
22
+ SPEC.getenv("BASE_URL") # -> os.environ.get("OPCLI_BASE_URL")
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import os
28
+ from dataclasses import dataclass
29
+ from pathlib import Path
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class AppSpec:
34
+ """Identity of one CLI. Frozen: this is configuration, not state."""
35
+
36
+ name: str
37
+ """Directory + keyring service name, e.g. ``op-cli``, ``drone-cli``."""
38
+
39
+ env_prefix: str
40
+ """Env-var namespace WITHOUT the trailing underscore, e.g. ``OPCLI``."""
41
+
42
+ def __post_init__(self) -> None:
43
+ # These two are the whole contract; a typo here silently relocates a
44
+ # user's config or splits their token from their profile.
45
+ if not self.name or "/" in self.name:
46
+ raise ValueError(f"AppSpec.name must be a bare directory name, got {self.name!r}")
47
+ if not self.env_prefix or not self.env_prefix.isupper():
48
+ raise ValueError(
49
+ f"AppSpec.env_prefix must be UPPERCASE and non-empty, got {self.env_prefix!r}"
50
+ )
51
+ if self.env_prefix.endswith("_"):
52
+ raise ValueError(
53
+ f"AppSpec.env_prefix must not end with '_' (it is added for you), got {self.env_prefix!r}"
54
+ )
55
+
56
+ # ---- env ---------------------------------------------------------
57
+
58
+ def env(self, suffix: str) -> str:
59
+ """The full env-var name for *suffix*: ``env("TOKEN") -> "OPCLI_TOKEN"``."""
60
+ return f"{self.env_prefix}_{suffix}"
61
+
62
+ def getenv(self, suffix: str, default: str | None = None) -> str | None:
63
+ """Read ``<PREFIX>_<SUFFIX>`` from the environment."""
64
+ return os.environ.get(self.env(suffix), default)
65
+
66
+ # ---- paths -------------------------------------------------------
67
+
68
+ def config_dir(self) -> Path:
69
+ """Where this tool's config lives.
70
+
71
+ A **function, not a module constant** — and that single property is what
72
+ makes the test suites hermetic. As a constant it would freeze at import
73
+ time, before a test could point ``<PREFIX>_CONFIG_DIR`` at a tmpdir, and
74
+ every test run would read and write the developer's real config.
75
+
76
+ Precedence: ``<PREFIX>_CONFIG_DIR`` > ``XDG_CONFIG_HOME``/<name> > ``~/.config/<name>``.
77
+ """
78
+ base = self.getenv("CONFIG_DIR")
79
+ if base:
80
+ return Path(base)
81
+ xdg = os.environ.get("XDG_CONFIG_HOME")
82
+ root = Path(xdg) if xdg else Path.home() / ".config"
83
+ return root / self.name
84
+
85
+ def config_file(self) -> Path:
86
+ return self.config_dir() / "config.json"
87
+
88
+ def credentials_file(self) -> Path:
89
+ """The 0600 fallback used only when no OS keyring is available."""
90
+ return self.config_dir() / "credentials.json"
91
+
92
+ # ---- keyring -----------------------------------------------------
93
+
94
+ @property
95
+ def keyring_service(self) -> str:
96
+ return self.name
@@ -0,0 +1,145 @@
1
+ """Secure storage of API tokens, shared by every agent-tool CLI.
2
+
3
+ Order of preference for reading a token:
4
+
5
+ 1. The ``<PREFIX>_TOKEN`` environment variable (used by CI / test suites and
6
+ handy for one-off scripting). **This is what makes a tool non-interactive** —
7
+ with it set, nothing touches the keyring and nothing can prompt.
8
+ 2. The operating-system keyring (Secret Service / macOS Keychain / Windows
9
+ Credential Locker) via the :mod:`keyring` library — the safe default.
10
+ 3. A ``0600`` fallback file in the config directory, used only when no real
11
+ keyring backend is available (headless boxes without a Secret Service).
12
+ We warn loudly in that case because the token is stored in clear text.
13
+
14
+ The token is the only secret we persist. Everything else (base URL, options)
15
+ lives in the plain-text config file.
16
+
17
+ Parameterized by :class:`~agentcli.appspec.AppSpec` — the keyring service, the
18
+ env var and the fallback path all derive from it, so two tools installed side by
19
+ side never read each other's tokens.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import os
26
+ import stat
27
+ import sys
28
+ from pathlib import Path
29
+
30
+ from .appspec import AppSpec
31
+
32
+
33
+ def _keyring_available() -> bool:
34
+ try:
35
+ import keyring
36
+ from keyring.backends import fail
37
+
38
+ # keyring always "works" — it just installs a null backend that raises on
39
+ # use. Only an isinstance check tells you whether it will actually store.
40
+ return not isinstance(keyring.get_keyring(), fail.Keyring)
41
+ except Exception:
42
+ return False
43
+
44
+
45
+ class Credentials:
46
+ """Token storage for one tool, identified by its :class:`AppSpec`."""
47
+
48
+ def __init__(self, spec: AppSpec) -> None:
49
+ self.spec = spec
50
+
51
+ # ---- internals ---------------------------------------------------
52
+
53
+ @property
54
+ def env_token(self) -> str:
55
+ return self.spec.env("TOKEN")
56
+
57
+ def _fallback_file(self) -> Path:
58
+ return self.spec.credentials_file()
59
+
60
+ def _read_fallback(self) -> dict[str, str]:
61
+ path = self._fallback_file()
62
+ if not path.exists():
63
+ return {}
64
+ try:
65
+ return json.loads(path.read_text())
66
+ except Exception:
67
+ return {}
68
+
69
+ def _write_fallback(self, data: dict[str, str]) -> None:
70
+ path = self._fallback_file()
71
+ path.parent.mkdir(parents=True, exist_ok=True)
72
+ # Create with 0600 from the start so the plaintext token is never briefly
73
+ # world-readable (mode 0o600 has no group/other bits, so umask can't widen it).
74
+ fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
75
+ with os.fdopen(fd, "w") as fh:
76
+ json.dump(data, fh, indent=2)
77
+ try:
78
+ path.chmod(stat.S_IRUSR | stat.S_IWUSR) # also fixes a pre-existing file
79
+ except OSError:
80
+ pass
81
+
82
+ # ---- api ---------------------------------------------------------
83
+
84
+ def backend_name(self) -> str:
85
+ """Human-readable name of the active secret backend (for `auth status`)."""
86
+ if os.environ.get(self.env_token):
87
+ return f"environment variable ${self.env_token}"
88
+ if _keyring_available():
89
+ try:
90
+ import keyring
91
+
92
+ return f"OS keyring ({keyring.get_keyring().__class__.__name__})"
93
+ except Exception:
94
+ pass
95
+ return f"plaintext fallback file ({self._fallback_file()})"
96
+
97
+ def store_token(self, profile: str, token: str) -> str:
98
+ """Persist *token* for *profile*. Returns the backend used."""
99
+ if _keyring_available():
100
+ try:
101
+ import keyring
102
+
103
+ keyring.set_password(self.spec.keyring_service, profile, token)
104
+ return "keyring"
105
+ except Exception as exc: # pragma: no cover - depends on host
106
+ print(f"warning: keyring store failed ({exc}); using fallback file", file=sys.stderr)
107
+ data = self._read_fallback()
108
+ data[profile] = token
109
+ self._write_fallback(data)
110
+ print(
111
+ f"warning: no OS keyring available — token stored in clear text at "
112
+ f"{self._fallback_file()} (0600)",
113
+ file=sys.stderr,
114
+ )
115
+ return "file"
116
+
117
+ def get_token(self, profile: str) -> str | None:
118
+ """Resolve the token for *profile*, honouring the env override first."""
119
+ env = os.environ.get(self.env_token)
120
+ if env:
121
+ return env
122
+ if _keyring_available():
123
+ try:
124
+ import keyring
125
+
126
+ tok = keyring.get_password(self.spec.keyring_service, profile)
127
+ if tok:
128
+ return tok
129
+ except Exception:
130
+ pass
131
+ return self._read_fallback().get(profile)
132
+
133
+ def delete_token(self, profile: str) -> None:
134
+ """Remove any stored token for *profile* from every backend."""
135
+ if _keyring_available():
136
+ try:
137
+ import keyring
138
+
139
+ keyring.delete_password(self.spec.keyring_service, profile)
140
+ except Exception:
141
+ pass
142
+ data = self._read_fallback()
143
+ if profile in data:
144
+ del data[profile]
145
+ self._write_fallback(data)
agentcli/errors.py ADDED
@@ -0,0 +1,93 @@
1
+ """Exception types for the the API CLI.
2
+
3
+ Every user-facing failure funnels through :class:`OpError` so the CLI entry
4
+ point can render a single, clean error line (and a JSON error object when
5
+ ``-o json`` is active) instead of a Python traceback.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+
13
+ class OpError(Exception):
14
+ """Base class for all expected, user-facing errors."""
15
+
16
+ exit_code = 1
17
+
18
+ def __init__(self, message: str, *, detail: Any = None):
19
+ super().__init__(message)
20
+ self.message = message
21
+ self.detail = detail
22
+
23
+ def to_dict(self) -> dict[str, Any]:
24
+ out: dict[str, Any] = {"error": self.message}
25
+ if self.detail is not None:
26
+ out["detail"] = self.detail
27
+ return out
28
+
29
+
30
+ class DryRun(Exception):
31
+ """Raised by the client in --dry-run mode instead of performing a write.
32
+
33
+ Carries the request that *would* have been sent so the CLI can print it and
34
+ exit 0 without touching the server.
35
+ """
36
+
37
+ def __init__(self, request: dict):
38
+ super().__init__("dry run")
39
+ self.request = request
40
+
41
+
42
+ class ConfigError(OpError):
43
+ """Something is wrong with configuration or stored credentials."""
44
+
45
+ exit_code = 3
46
+
47
+
48
+ class AuthError(OpError):
49
+ """Authentication/authorization failed (401/403)."""
50
+
51
+ exit_code = 4
52
+
53
+
54
+ class NotFoundError(OpError):
55
+ """A requested resource does not exist (404)."""
56
+
57
+ exit_code = 5
58
+
59
+
60
+ class ConflictError(OpError):
61
+ """Optimistic-locking or uniqueness conflict (409/422 stale lockVersion)."""
62
+
63
+ exit_code = 6
64
+
65
+
66
+ class ValidationError(OpError):
67
+ """The server rejected the request payload (422)."""
68
+
69
+ exit_code = 7
70
+
71
+ def __init__(self, message: str, *, detail: Any = None, field_errors: Any = None):
72
+ super().__init__(message, detail=detail)
73
+ self.field_errors = field_errors
74
+
75
+ def to_dict(self) -> dict[str, Any]:
76
+ out = super().to_dict()
77
+ if self.field_errors:
78
+ out["fieldErrors"] = self.field_errors
79
+ return out
80
+
81
+
82
+ class ApiError(OpError):
83
+ """A non-specific API error carrying the HTTP status and server payload."""
84
+
85
+ def __init__(self, message: str, *, status: int | None = None, detail: Any = None):
86
+ super().__init__(message, detail=detail)
87
+ self.status = status
88
+
89
+ def to_dict(self) -> dict[str, Any]:
90
+ out = super().to_dict()
91
+ if self.status is not None:
92
+ out["status"] = self.status
93
+ return out
agentcli/output.py ADDED
@@ -0,0 +1,288 @@
1
+ """Rendering of command results.
2
+
3
+ The CLI is *agent-first*, so JSON is the default output: stable, complete, and
4
+ trivial to parse. A ``table`` mode (Rich) exists for humans. Commands hand the
5
+ formatter the raw data plus an optional column spec; the formatter decides how
6
+ to present it based on the active ``--output`` mode.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import csv as csvlib
12
+ import dataclasses
13
+ import enum
14
+ import json as jsonlib
15
+ import sys
16
+ from typing import Any, Callable, Iterable, Sequence
17
+
18
+ from rich.console import Console
19
+ from rich.table import Table
20
+
21
+ # A column is (header, accessor). accessor is a dict key or a callable(row)->value.
22
+ Column = tuple[str, "str | Callable[[dict], Any]"]
23
+
24
+ _err_console = Console(stderr=True)
25
+
26
+
27
+ class OutputFormat(str, enum.Enum):
28
+ json = "json"
29
+ table = "table"
30
+ markdown = "markdown"
31
+ csv = "csv"
32
+
33
+ @classmethod
34
+ def coerce(cls, value: "str | OutputFormat | None") -> "OutputFormat | None":
35
+ """Parse a loose string (accepts 'md' for markdown). None passes through."""
36
+ if value is None or isinstance(value, cls):
37
+ return value
38
+ v = str(value).strip().lower()
39
+ if v in ("md", "markdown"):
40
+ return cls.markdown
41
+ if v in ("json", "j"):
42
+ return cls.json
43
+ if v in ("table", "tbl", "t"):
44
+ return cls.table
45
+ if v in ("csv",):
46
+ return cls.csv
47
+ raise ValueError(f"unknown output format '{value}' (choose json, table, markdown, or csv)")
48
+
49
+
50
+ def _accessor_value(row: dict, accessor: "str | Callable[[dict], Any]") -> Any:
51
+ if callable(accessor):
52
+ try:
53
+ return accessor(row)
54
+ except Exception:
55
+ return None
56
+ return row.get(accessor)
57
+
58
+
59
+ def _fmt_cell(value: Any) -> str:
60
+ if value is None:
61
+ return ""
62
+ if isinstance(value, bool):
63
+ return "yes" if value else "no"
64
+ if isinstance(value, (list, tuple)):
65
+ return ", ".join(str(v) for v in value)
66
+ return str(value)
67
+
68
+
69
+ class Emitter:
70
+ def __init__(
71
+ self,
72
+ fmt: OutputFormat = OutputFormat.json,
73
+ *,
74
+ color: bool = True,
75
+ fields: Sequence[str] | None = None,
76
+ stream: bool = False,
77
+ ):
78
+ self.fmt = fmt
79
+ self.console = Console(no_color=not color, highlight=False)
80
+ # user-selected fields (dotted paths ok, e.g. "assignee.name"); None = command defaults
81
+ self.fields = [f.strip() for f in fields if f.strip()] if fields else None
82
+ self.stream = stream
83
+
84
+ # ---- main entry --------------------------------------------------
85
+ def emit(
86
+ self,
87
+ data: Any,
88
+ *,
89
+ columns: Sequence[Column] | None = None,
90
+ title: str | None = None,
91
+ empty: str = "(no results)",
92
+ ) -> None:
93
+ if self.fields:
94
+ # --fields overrides both the JSON shape and the table/markdown columns
95
+ if self.fmt == OutputFormat.json:
96
+ self._emit_json(_project(data, self.fields))
97
+ return
98
+ columns = [(f, (lambda r, _f=f: _dotted_get(r, _f))) for f in self.fields]
99
+
100
+ if self.fmt == OutputFormat.json:
101
+ self._emit_json(data)
102
+ return
103
+ if self.fmt == OutputFormat.csv:
104
+ self._emit_csv(data, columns=columns)
105
+ return
106
+ if self.fmt == OutputFormat.markdown:
107
+ self._emit_markdown(data, columns=columns, title=title, empty=empty)
108
+ return
109
+ self._emit_table(data, columns=columns, title=title, empty=empty)
110
+
111
+ def stream_json(self, items: Iterable[Any]) -> int:
112
+ """Emit an iterable as NDJSON — one JSON object per line, flushed as it
113
+ arrives. Honours --fields. Returns the number of items written."""
114
+ n = 0
115
+ for it in items:
116
+ obj = {f: _dotted_get(it, f) for f in self.fields} if (self.fields and isinstance(it, dict)) else it
117
+ sys.stdout.write(jsonlib.dumps(_jsonable(obj), ensure_ascii=False, default=str) + "\n")
118
+ sys.stdout.flush()
119
+ n += 1
120
+ return n
121
+
122
+ def message(self, text: str) -> None:
123
+ """A human status line (table mode only; suppressed in every machine format).
124
+
125
+ Allowlist, not denylist: `!= json` would also let this print into csv and
126
+ markdown, corrupting both. stdout is a machine channel (see AGENTS.md) —
127
+ only the one human-facing format may carry prose.
128
+ """
129
+ if self.fmt == OutputFormat.table:
130
+ self.console.print(text)
131
+
132
+ # ---- json --------------------------------------------------------
133
+ def _emit_json(self, data: Any) -> None:
134
+ sys.stdout.write(jsonlib.dumps(_jsonable(data), indent=2, ensure_ascii=False, default=str))
135
+ sys.stdout.write("\n")
136
+
137
+ # ---- table -------------------------------------------------------
138
+ def _emit_table(
139
+ self,
140
+ data: Any,
141
+ *,
142
+ columns: Sequence[Column] | None,
143
+ title: str | None,
144
+ empty: str,
145
+ ) -> None:
146
+ rows = data if isinstance(data, list) else [data] if isinstance(data, dict) else None
147
+ if rows is None:
148
+ self.console.print(str(data))
149
+ return
150
+ if not rows:
151
+ self.console.print(f"[dim]{empty}[/dim]")
152
+ return
153
+
154
+ if columns is None:
155
+ # key/value table for a single object, else fall back to JSON.
156
+ if len(rows) == 1 and isinstance(rows[0], dict):
157
+ self._kv_table(rows[0], title=title)
158
+ return
159
+ self._emit_json(data)
160
+ return
161
+
162
+ table = Table(title=title, show_lines=False, header_style="bold")
163
+ for header, _ in columns:
164
+ table.add_column(header)
165
+ for row in rows:
166
+ table.add_row(*[_fmt_cell(_accessor_value(row, acc)) for _, acc in columns])
167
+ self.console.print(table)
168
+
169
+ # ---- csv ---------------------------------------------------------
170
+ def _emit_csv(self, data: Any, *, columns) -> None:
171
+ rows = data if isinstance(data, list) else [data] if isinstance(data, dict) else None
172
+ if rows is None:
173
+ sys.stdout.write(str(data) + "\n")
174
+ return
175
+ writer = csvlib.writer(sys.stdout)
176
+ if columns:
177
+ writer.writerow([h for h, _ in columns])
178
+ for r in rows:
179
+ writer.writerow([_csv_cell(_accessor_value(r, acc)) for _, acc in columns])
180
+ elif rows and isinstance(rows[0], dict):
181
+ # union of keys preserves columns even if some rows omit a field
182
+ keys: list[str] = []
183
+ for r in rows:
184
+ for k in r:
185
+ if k not in keys:
186
+ keys.append(k)
187
+ writer.writerow(keys)
188
+ for r in rows:
189
+ writer.writerow([_csv_cell(r.get(k)) for k in keys])
190
+
191
+ # ---- markdown ----------------------------------------------------
192
+ def _emit_markdown(self, data: Any, *, columns, title, empty) -> None:
193
+ out = sys.stdout
194
+ if title:
195
+ out.write(f"### {title}\n\n")
196
+ rows = data if isinstance(data, list) else [data] if isinstance(data, dict) else None
197
+ if rows is None:
198
+ out.write(f"{data}\n")
199
+ return
200
+ if not rows:
201
+ out.write(f"_{empty}_\n")
202
+ return
203
+
204
+ if columns is not None:
205
+ headers = [h for h, _ in columns]
206
+ cells = [[_md_cell(_accessor_value(r, acc)) for _, acc in columns] for r in rows]
207
+ out.write(_md_table(headers, cells))
208
+ return
209
+ if len(rows) == 1 and isinstance(rows[0], dict):
210
+ # single object -> Field/Value table
211
+ cells = [[_md_cell(k), _md_cell(v)] for k, v in rows[0].items()]
212
+ out.write(_md_table(["Field", "Value"], cells))
213
+ return
214
+ # list without a column spec -> fenced JSON (still valid markdown)
215
+ out.write("```json\n")
216
+ out.write(jsonlib.dumps(_jsonable(data), indent=2, ensure_ascii=False, default=str))
217
+ out.write("\n```\n")
218
+
219
+ def _kv_table(self, obj: dict, *, title: str | None) -> None:
220
+ table = Table(title=title, show_header=False, box=None)
221
+ table.add_column("field", style="bold cyan")
222
+ table.add_column("value")
223
+ for key, value in obj.items():
224
+ table.add_row(key, _fmt_cell(value))
225
+ self.console.print(table)
226
+
227
+
228
+ def print_error(err: Any, fmt: OutputFormat) -> None:
229
+ """Render an error to stderr in the active format."""
230
+ if fmt == OutputFormat.json:
231
+ from .errors import OpError
232
+
233
+ payload = err.to_dict() if isinstance(err, OpError) else {"error": str(err)}
234
+ _err_console.file.write(jsonlib.dumps(payload, indent=2, ensure_ascii=False, default=str) + "\n")
235
+ else:
236
+ _err_console.print(f"[red]error:[/red] {err}")
237
+
238
+
239
+ def _dotted_get(row: Any, path: str) -> Any:
240
+ """Fetch a possibly-nested value by dotted path, e.g. ``assignee.name``."""
241
+ cur = row
242
+ for part in path.split("."):
243
+ if isinstance(cur, dict):
244
+ cur = cur.get(part)
245
+ else:
246
+ return None
247
+ return cur
248
+
249
+
250
+ def _project(data: Any, fields: Sequence[str]) -> Any:
251
+ """Keep only the selected fields (dotted paths allowed). The dotted string is
252
+ used as the output key so the projection is flat and predictable."""
253
+ if isinstance(data, list):
254
+ return [{f: _dotted_get(r, f) for f in fields} if isinstance(r, dict) else r for r in data]
255
+ if isinstance(data, dict):
256
+ return {f: _dotted_get(data, f) for f in fields}
257
+ return data
258
+
259
+
260
+ def _csv_cell(value: Any) -> str:
261
+ if value is None:
262
+ return ""
263
+ if isinstance(value, bool):
264
+ return "true" if value else "false"
265
+ if isinstance(value, (dict, list)):
266
+ return jsonlib.dumps(value, ensure_ascii=False, default=str)
267
+ return str(value)
268
+
269
+
270
+ def _md_cell(value: Any) -> str:
271
+ text = _fmt_cell(value)
272
+ # keep the table one row per record: escape pipes and flatten newlines
273
+ return text.replace("\\", "\\\\").replace("|", "\\|").replace("\n", "<br>")
274
+
275
+
276
+ def _md_table(headers: Sequence[str], rows: Sequence[Sequence[str]]) -> str:
277
+ lines = ["| " + " | ".join(headers) + " |", "| " + " | ".join("---" for _ in headers) + " |"]
278
+ for row in rows:
279
+ lines.append("| " + " | ".join(row) + " |")
280
+ return "\n".join(lines) + "\n"
281
+
282
+
283
+ def _jsonable(obj: Any) -> Any:
284
+ if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
285
+ return dataclasses.asdict(obj)
286
+ if isinstance(obj, enum.Enum):
287
+ return obj.value
288
+ return obj