Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Guidance for AI coding assistants working in this repository.

## TL;DR

- `src/onepin/_cli/` and `tests/` are **hand-rolled** — edit freely.
- `src/onepin/_cli/`, `src/onepin/_version_gate.py`, and `tests/` are **hand-rolled** — edit freely.
- Everything else under `src/onepin/` is **generated by Fern** from the OpenAPI spec — do not edit; changes will be lost on the next regen.
- Lint and tests must be scoped: never run `ruff check .` or `pytest src/onepin/`.

Expand All @@ -13,10 +13,11 @@ Guidance for AI coding assistants working in this repository.
| Path | Status | Editable? |
|------|--------|-----------|
| `src/onepin/_cli/` | Hand-rolled Typer CLI | YES |
| `src/onepin/` (excluding `_cli/`) | Fern-generated SDK | NO — overwritten on every regen |
| `src/onepin/` (excluding `_cli/` + `_version_gate.py`) | Fern-generated SDK | NO — overwritten on every regen |
| `src/onepin/_version_gate.py` | Hand-rolled SDK version gate (preserved via `.fernignore`; re-exported via `generators.yml`) | YES |
| `scripts/post_fern.sh` | Restores `py.typed` markers after regen | YES |
| `fern/` | Fern config (`fern.config.json`, `generators.yml`) for in-repo self-generation | YES |
| `src/onepin/.fernignore` | Paths Fern preserves on regen (`_cli/`, `py.typed`) | YES |
| `src/onepin/.fernignore` | Paths Fern preserves on regen (`_cli/`, `_version_gate.py`, `py.typed`) | YES |
| `.github/workflows/regen.yml` | Self-generates the SDK from the OnePin API spec | YES |
| `tests/` | Project tests (`unit/`, `cli/`, `build/`) | YES |
| `src/onepin/tests/` | SDK-bundled tests | NOT COLLECTED — `testpaths = ["tests"]` |
Expand All @@ -29,10 +30,16 @@ Files under `src/onepin/` (excluding `_cli/`) are generated by [Fern](https://bu

**If you need to change generated behavior, change the upstream OpenAPI spec.** Generation runs *in this repo*: `.github/workflows/regen.yml` fetches the shaped spec and runs `fern generate --local` (config in `fern/`), then opens a regen PR.

`src/onepin/.fernignore` lists the hand-rolled paths Fern preserves across regeneration (`_cli/`, `py.typed`). After every regen, `scripts/post_fern.sh` restores the PEP 561 markers (CI runs it automatically; local regens require running it by hand). `tests/build/test_cli_preserved.py` guards both.
`src/onepin/.fernignore` lists the hand-rolled paths Fern preserves across regeneration (`_cli/`, `_version_gate.py`, `py.typed`). After every regen, `scripts/post_fern.sh` restores the PEP 561 markers (CI runs it automatically; local regens require running it by hand). `tests/build/test_cli_preserved.py` guards both.

If a patch on a generated file becomes unavoidable, add it to `src/onepin/.fernignore` with a comment explaining why, and document the patch in this file under a new "Active patches" section.

## Active patches

Patches applied to generated files (kept minimal; each must be reproducible on regen):

- **`src/onepin/__init__.py`** — re-exports `make_client`, `make_async_client`, and `OnePinUpgradeRequiredError` from the hand-rolled `_version_gate` (lazy `_dynamic_imports` + `__all__` entries). Reproduced automatically by the `additional_init_exports` block in `fern/generators.yml`, so the hand edit is only a bridge until the next `fern generate`.

## CLI subpackage (`src/onepin/_cli/`)

| Module | Role |
Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,29 @@ your request is about OnePin. After the first install, restart your tool (or run
in Claude Code) so it picks up the new skills directory. The skill drives the same `onepin` CLI, so
run `onepin login` first.

## Version compatibility

The OnePin API advertises the minimum SDK version it still accepts. When a response indicates your
installed `onepin` is **below that floor**, the SDK stops with a clear, copy-paste upgrade message:

```
onepin 0.4.1 is below the required minimum 0.5.0. Upgrade: pip install --upgrade 'onepin>=0.5.0'
```

The `onepin` CLI enforces this automatically. For **programmatic** use, build the client with
`onepin.make_client` (instead of `OnePinClient` directly) to get the same gate plus a corrected
`User-Agent`:

```python
import onepin

client = onepin.make_client(token="op_live_...")
client.workflows.list() # raises onepin.OnePinUpgradeRequiredError if the SDK is too old
```

The CLI also nudges you when a newer release is available on PyPI (surfaced through the OnePin agent
skill). Set `ONEPIN_NO_UPDATE_CHECK=1` to silence the recommended-upgrade check.

## Command reference

The CLI groups its commands by resource. Every group prints its own command list with
Expand Down
9 changes: 9 additions & 0 deletions fern/generators.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,12 @@ groups:
client_class_name: OnePinClient
package_name: onepin
flat_layout: false
# Re-export the hand-rolled version gate (src/onepin/_version_gate.py, preserved by
# .fernignore) from the generated package __init__ on every regen, so programmatic
# users get `from onepin import make_client` (a version-gated OnePinClient).
additional_init_exports:
- from: _version_gate
imports:
- make_client
- make_async_client
- OnePinUpgradeRequiredError
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ dependencies = [
"typer>=0.12,<1.0",
"rich>=13",
"pydantic>=2.7,<3.0",
"packaging>=23",
"tomli; python_version<\"3.11\"",
]

Expand Down
1 change: 1 addition & 0 deletions src/onepin/.fernignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@
# Fern auto-discovers this file in the generator's local-file-system output dir
# (src/onepin/) and never overwrites the paths listed here.
_cli/
_version_gate.py
py.typed
8 changes: 8 additions & 0 deletions src/onepin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,11 @@
"workspace_aggregates": ".workspace_aggregates",
"workspace_members": ".workspace_members",
"workspaces": ".workspaces",
# Hand-rolled version gate, re-exported here. Also declared in
# fern/generators.yml `additional_init_exports`, so `fern generate` reproduces these.
"OnePinUpgradeRequiredError": "._version_gate",
"make_async_client": "._version_gate",
"make_client": "._version_gate",
}


Expand Down Expand Up @@ -795,4 +800,7 @@ def __dir__():
"workspace_aggregates",
"workspace_members",
"workspaces",
"OnePinUpgradeRequiredError",
"make_async_client",
"make_client",
]
40 changes: 38 additions & 2 deletions src/onepin/_cli/_ctx.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ def build_client(creds: ResolvedCredentials) -> OnePinClient:
(guards against sending the token to a ``file://``/``ftp://`` host).
"""
# Deferred import: importing _ctx must stay cheap (no SDK on `onepin --help`).
from onepin.client import OnePinClient
# make_client wraps OnePinClient with the version-gate response hook + a corrected
# User-Agent (the generated default is baked at codegen time and can be stale).
from onepin._version_gate import make_client

if not creds.api_key:
raise OnePinAuthError(
Expand All @@ -71,7 +73,7 @@ def build_client(creds: ResolvedCredentials) -> OnePinClient:
f"Invalid base URL {creds.base_url!r}: only http and https are supported.",
error_code="INVALID_BASE_URL",
)
return OnePinClient(base_url=creds.base_url, token=creds.api_key)
return make_client(base_url=creds.base_url, token=creds.api_key)


def _is_commandline_source(value: object) -> bool:
Expand Down Expand Up @@ -164,6 +166,7 @@ def api_errors(json_out: bool) -> Iterator[None]:
json_out: Whether to emit the structured-JSON error envelope.
"""
# Deferred: these SDK error classes live under the generated tree.
from onepin._version_gate import OnePinUpgradeRequiredError
from onepin.core.api_error import ApiError
from onepin.core.parse_error import ParsingError
from onepin.errors import ConflictError, NotFoundError, UnprocessableEntityError
Expand Down Expand Up @@ -192,7 +195,16 @@ def api_errors(json_out: bool) -> Iterator[None]:
json_out,
)
raise SystemExit(1) from exc
except OnePinUpgradeRequiredError as exc:
# Client-side gate (response hook) saw an install below the required floor.
_emit_upgrade_required(str(exc), json_out)
raise SystemExit(1) from exc
except (NotFoundError, ConflictError, UnprocessableEntityError, ApiError) as exc:
# 426 Upgrade Required: the server hard-floors the SDK version. Surface it as the
# same yellow upgrade message instead of a generic API error.
if getattr(exc, "status_code", None) == 426:
_emit_upgrade_required(_format_upgrade_required(exc), json_out)
raise SystemExit(1) from exc
code, message, request_id = _classify_api_error(exc)
_emit_error(code, message, request_id, json_out)
raise SystemExit(1) from exc
Expand Down Expand Up @@ -295,3 +307,27 @@ def _emit_error(code: str, message: str, request_id: str | None, json_out: bool)
else:
rid = f" (request_id={request_id})" if request_id else ""
print(f"[{code}] {message}{rid}", file=sys.stderr)


def _emit_upgrade_required(message: str, json_out: bool) -> None:
"""Emit a version-floor failure: machine envelope under --json, else a yellow stderr note."""
if json_out:
_emit_error("UPGRADE_REQUIRED", message, None, json_out)
return
from onepin._cli.render import echo_warning

echo_warning(message)


def _format_upgrade_required(exc: Exception) -> str:
"""Build an upgrade message from a 426 ApiError body (best-effort), else a generic one."""
from onepin._version_gate import format_upgrade_message

required = None
body = getattr(exc, "body", None)
if isinstance(body, dict):
detail = body.get("error") if isinstance(body.get("error"), dict) else body
if isinstance(detail, dict):
raw = detail.get("required_version") or detail.get("minimum_version")
required = raw.strip() if isinstance(raw, str) and raw.strip() else None
return format_upgrade_message(required)
21 changes: 21 additions & 0 deletions src/onepin/_cli/_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,27 @@ def _call_whoami(key: str, base_url: str, timeout: float = 10.0, *, verbose: boo
error_code = parsed.get("code", error_code)
error_message = parsed.get("message", error_message)

if response.status_code == 426:
# Server-enforced SDK floor. The raw auth path bypasses the SDK's api_errors()
# mapper, so surface the same UPGRADE_REQUIRED message + copy-paste command here.
from onepin._version_gate import format_upgrade_message, required_version_from

required = required_version_from(dict(response.headers))
if not required:
try:
err = response.json().get("error")
raw = err.get("required_version") or err.get("minimum_version") if isinstance(err, dict) else None
required = raw.strip() if isinstance(raw, str) and raw.strip() else None
except Exception: # noqa: BLE001 # pragma: no cover - defensive 426 body-parse guard
required = None
raise OnePinHTTPError(
format_upgrade_message(required),
status_code=426,
error_code="UPGRADE_REQUIRED",
request_id=request_id,
response_body=body_text,
)

if response.status_code in (401, 403):
raise OnePinAuthError(
error_message,
Expand Down
10 changes: 10 additions & 0 deletions src/onepin/_cli/_skill/onepin/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,16 @@ integration surface — this skill teaches its contract, not a frozen command li
workspace and the wrong one is active.
3. If `onepin schema` errors or the command is missing, the CLI is old — suggest
`pip install -U onepin`, or fall back to `onepin <group> --help`.
4. **Offer upgrades (advisory, never block the task).** Run `onepin upgrade-check` (it prints
nothing when up to date, snoozed, offline, or disabled via `ONEPIN_NO_UPDATE_CHECK`).
- On `UPGRADE_AVAILABLE <current> <latest>`: ask with AskUserQuestion — **Upgrade now**
(run `onepin upgrade-check --mark-upgrading && pip install --upgrade onepin` — the marker lets
the next run confirm the new version), **Not now** (run `onepin upgrade-check --snooze` — an
escalating quiet period: 24h, then 48h, then 7d), or **Don't ask again** (run
`onepin upgrade-check --disable`). Continue with the task either way.
- On `JUST_UPGRADED <old> <new>`: tell the user they're now on v`<new>` and continue.
Separately, if *any* command fails with `UPGRADE_REQUIRED` (or an HTTP 426), the SDK is too old to
talk to the API — surface the message and its `pip install --upgrade` command, and stop.

## Discover, don't guess

Expand Down
19 changes: 2 additions & 17 deletions src/onepin/_cli/_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -867,21 +867,6 @@ def _list_opts(*extra: Opt) -> list[Opt]:
options=[_JSON],
unwrap="data",
),
# --- health -------------------------------------------------------------------------
Cmd(
"health",
"live",
"health.liveness",
"Liveness probe.",
options=[_JSON],
unwrap="data",
),
Cmd(
"health",
"ready",
"health.readiness",
"Readiness probe.",
options=[_JSON],
unwrap="data",
),
# health (live/ready) is hand-written in commands/health.py -- it blends local SDK version,
# the API's reported version, and version-gate headers, which the table model can't express.
]
Loading
Loading