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.
- agent_tool_shared_cli-0.1.0.dist-info/METADATA +120 -0
- agent_tool_shared_cli-0.1.0.dist-info/RECORD +9 -0
- agent_tool_shared_cli-0.1.0.dist-info/WHEEL +5 -0
- agent_tool_shared_cli-0.1.0.dist-info/top_level.txt +1 -0
- agentcli/__init__.py +58 -0
- agentcli/appspec.py +96 -0
- agentcli/credentials.py +145 -0
- agentcli/errors.py +93 -0
- agentcli/output.py +288 -0
|
@@ -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 @@
|
|
|
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
|
agentcli/credentials.py
ADDED
|
@@ -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
|