agent-tool-shared-cli 0.1.0__tar.gz

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,95 @@
1
+ # agent-tool-shared-cli
2
+
3
+ The shared chassis for the **`agent-tool-<x>-cli`** family — agent-ready command-line
4
+ tools that an LLM can drive with no prior knowledge of them.
5
+
6
+ Consumers: [`agent-tool-openproject-cli`](https://github.com/alexander-zierhut/agent-tool-openproject-cli),
7
+ `agent-tool-drone-cli` (in progress).
8
+
9
+ This repo also holds **[BLUEPRINT.md](BLUEPRINT.md)** — the engineering standard the
10
+ whole family is built to. Read that first if you are standing up a new tool.
11
+
12
+ ```bash
13
+ pip install agent-tool-shared-cli
14
+ ```
15
+
16
+ ## What's in it
17
+
18
+ This package is deliberately small. It holds **the agent contract** and the pure
19
+ utilities that implement it — the things that must be *identical* across every tool,
20
+ because an agent learns the contract once and applies it everywhere.
21
+
22
+ | Module | What |
23
+ |---|---|
24
+ | `agentcli.errors` | The exit-code taxonomy (`0/1/3/4/5/6/7/130`) + `DryRun` |
25
+ | `agentcli.output` | `Emitter`: json/table/markdown/csv, `--fields` projection, NDJSON streaming |
26
+ | `agentcli.credentials` | Keyring storage with a `0600` fallback and an env override |
27
+ | `agentcli.appspec` | `AppSpec` — the two strings that make all of the above tool-specific |
28
+
29
+ ```python
30
+ from agentcli import AppSpec, Credentials, Emitter, OutputFormat, NotFoundError
31
+
32
+ SPEC = AppSpec(name="drone-cli", env_prefix="DRONECLI")
33
+
34
+ SPEC.config_dir() # ~/.config/drone-cli (or $DRONECLI_CONFIG_DIR)
35
+ SPEC.env("TOKEN") # "DRONECLI_TOKEN"
36
+
37
+ Credentials(SPEC).get_token("default") # env > keyring > 0600 file
38
+ Emitter(OutputFormat.json, fields=["id", "status"]).emit(rows)
39
+ raise NotFoundError("no such build") # -> exit 5, JSON on stderr
40
+ ```
41
+
42
+ ## The exit-code contract
43
+
44
+ Published API. You may leave a code unallocated, or repurpose one deliberately.
45
+ **You may never renumber one** — agents branch on these, and they are documented in
46
+ three places per tool (README, the in-binary `guide`, and the Claude skill).
47
+
48
+ | Code | Meaning |
49
+ |---|---|
50
+ | 0 | success (including a successful `--dry-run`) |
51
+ | 1 | generic error |
52
+ | 2 | *reserved for Click/Typer usage errors — never allocate* |
53
+ | 3 | config error |
54
+ | 4 | auth error (401/403) |
55
+ | 5 | not found (404) |
56
+ | 6 | conflict (409 / optimistic locking) |
57
+ | 7 | validation error (422) — see `fieldErrors` |
58
+ | 8+ | per-tool, and only for a condition you have **observed** |
59
+ | 130 | SIGINT |
60
+
61
+ ## What is deliberately NOT here
62
+
63
+ `client.py`, `serialize.py`, `resolve.py` and the domain commands stay in each tool.
64
+ They *look* shareable and are not:
65
+
66
+ > OpenProject stops paginating on an authoritative `total` — "never stop on a short
67
+ > page". Drone has no total, and a short page **is** the terminator. The rule that
68
+ > inverts between tool #1 and tool #2 is exactly the rule you must not hoist.
69
+
70
+ Auth schemes, retry matrices and error-body shapes are the same story. Pulling them in
71
+ would make this a framework with a config object per tool, which is how shared-code
72
+ projects die.
73
+
74
+ **Share the contract, not the transport.**
75
+
76
+ ## Contributing
77
+
78
+ You need nothing but Python:
79
+
80
+ ```bash
81
+ pip install -e '.[test]'
82
+ pytest # 74 tests, no network, no services
83
+ ```
84
+
85
+ Changes here affect every downstream CLI. Two rules:
86
+
87
+ 1. **Never renumber an exit code, and never change an output shape** without treating
88
+ it as a breaking change — downstream agents depend on both.
89
+ 2. **Don't add a module because two tools happen to share it today.** Wait until a
90
+ third does, and until you can state the rule it obeys. `tests/test_contract.py`
91
+ is the tripwire: if a change makes you edit it, stop and think.
92
+
93
+ ## License
94
+
95
+ MIT
@@ -0,0 +1,56 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "agent-tool-shared-cli"
7
+ description = "Shared chassis for agent-ready CLIs: the exit-code contract, JSON/table/markdown/CSV output with field projection, and keyring-backed credentials."
8
+ readme = "README.md"
9
+ requires-python = ">=3.10"
10
+ license = { text = "MIT" }
11
+ authors = [{ name = "Zierhut IT" }, { name = "Alexander Zierhut" }]
12
+ dynamic = ["version"]
13
+ keywords = [
14
+ "cli", "command-line", "ai-agent", "llm", "automation",
15
+ "json-output", "keyring", "exit-codes", "agent-ready",
16
+ ]
17
+ classifiers = [
18
+ "Development Status :: 4 - Beta",
19
+ "Environment :: Console",
20
+ "Intended Audience :: Developers",
21
+ "License :: OSI Approved :: MIT License",
22
+ "Programming Language :: Python :: 3",
23
+ "Topic :: Software Development :: Libraries",
24
+ "Topic :: Utilities",
25
+ ]
26
+ dependencies = [
27
+ "keyring>=25",
28
+ "rich>=13",
29
+ ]
30
+
31
+ [project.optional-dependencies]
32
+ test = [
33
+ "pytest>=8",
34
+ "pytest-timeout>=2.3",
35
+ ]
36
+
37
+ [project.urls]
38
+ Homepage = "https://github.com/alexander-zierhut/agent-tool-shared-cli"
39
+ Repository = "https://github.com/alexander-zierhut/agent-tool-shared-cli"
40
+ Issues = "https://github.com/alexander-zierhut/agent-tool-shared-cli/issues"
41
+
42
+ # No [project.scripts]: this is a library, not a command.
43
+
44
+ [tool.setuptools.packages.find]
45
+ where = ["src"]
46
+
47
+ # Single source of truth for the version — the scar opcli carries (it duplicates
48
+ # the number in pyproject.toml AND __init__.py, so a bump can ship a wheel that
49
+ # reports the wrong version in its User-Agent and in the skill it writes).
50
+ [tool.setuptools.dynamic]
51
+ version = { attr = "agentcli.__version__" }
52
+
53
+ [tool.pytest.ini_options]
54
+ testpaths = ["tests"]
55
+ addopts = "-ra -q"
56
+ timeout = 60
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,16 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/agent_tool_shared_cli.egg-info/PKG-INFO
4
+ src/agent_tool_shared_cli.egg-info/SOURCES.txt
5
+ src/agent_tool_shared_cli.egg-info/dependency_links.txt
6
+ src/agent_tool_shared_cli.egg-info/requires.txt
7
+ src/agent_tool_shared_cli.egg-info/top_level.txt
8
+ src/agentcli/__init__.py
9
+ src/agentcli/appspec.py
10
+ src/agentcli/credentials.py
11
+ src/agentcli/errors.py
12
+ src/agentcli/output.py
13
+ tests/test_appspec.py
14
+ tests/test_contract.py
15
+ tests/test_credentials.py
16
+ tests/test_output_render.py
@@ -0,0 +1,6 @@
1
+ keyring>=25
2
+ rich>=13
3
+
4
+ [test]
5
+ pytest>=8
6
+ pytest-timeout>=2.3
@@ -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
+ ]
@@ -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