openapi-cli4ai 0.6.0__tar.gz → 0.7.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.
Files changed (65) hide show
  1. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/.github/workflows/ci.yml +3 -3
  2. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/.github/workflows/codeql.yml +2 -2
  3. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/.github/workflows/publish.yml +3 -3
  4. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/.github/workflows/validate-profiles.yml +3 -1
  5. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/CHANGELOG.md +22 -0
  6. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/CONTRIBUTING.md +17 -6
  7. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/PKG-INFO +4 -2
  8. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/README.md +2 -0
  9. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/profiles/README.md +41 -2
  10. openapi_cli4ai-0.7.0/profiles/index.json +33 -0
  11. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/pyproject.toml +16 -5
  12. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/src/openapi_cli4ai/__init__.py +1 -1
  13. openapi_cli4ai-0.7.0/src/openapi_cli4ai/_state.py +22 -0
  14. openapi_cli4ai-0.7.0/src/openapi_cli4ai/_ui.py +16 -0
  15. openapi_cli4ai-0.7.0/src/openapi_cli4ai/catalog.py +247 -0
  16. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/src/openapi_cli4ai/cli.py +223 -513
  17. openapi_cli4ai-0.7.0/src/openapi_cli4ai/config.py +254 -0
  18. openapi_cli4ai-0.7.0/src/openapi_cli4ai/validator.py +253 -0
  19. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/tests/conftest.py +13 -3
  20. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/tests/test_auth_flow_security.py +2 -2
  21. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/tests/test_auth_flows.py +5 -8
  22. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/tests/test_caching.py +1 -1
  23. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/tests/test_catalog.py +89 -15
  24. openapi_cli4ai-0.7.0/tests/test_catalog_remote.py +162 -0
  25. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/tests/test_device_flow.py +0 -1
  26. openapi_cli4ai-0.7.0/tests/test_domain_verified.py +122 -0
  27. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/tests/test_error_handling.py +10 -8
  28. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/tests/test_features.py +52 -51
  29. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/tests/test_foundation.py +12 -10
  30. openapi_cli4ai-0.7.0/tests/test_profile_anywhere.py +79 -0
  31. openapi_cli4ai-0.7.0/tests/test_profiles_d.py +119 -0
  32. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/tests/test_schema_composition.py +0 -1
  33. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/tests/test_security.py +5 -7
  34. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/tests/test_v040_hardening.py +11 -10
  35. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/uv.lock +94 -78
  36. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/.github/CODEOWNERS +0 -0
  37. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  38. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  39. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/.github/dependabot.yml +0 -0
  40. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/.github/pull_request_template.md +0 -0
  41. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/.github/release.yml +0 -0
  42. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/.gitignore +0 -0
  43. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/.pre-commit-config.yaml +0 -0
  44. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/LICENSE +0 -0
  45. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/SECURITY.md +0 -0
  46. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/demo.gif +0 -0
  47. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/examples/profiles.toml.example +0 -0
  48. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/openapi-cli4ai +0 -0
  49. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/profiles/community/.gitkeep +0 -0
  50. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/profiles/community/xquik.toml +0 -0
  51. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/profiles/verified/petstore.toml +0 -0
  52. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/src/openapi_cli4ai/__main__.py +0 -0
  53. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/src/openapi_cli4ai/py.typed +0 -0
  54. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/tests/__init__.py +0 -0
  55. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/tests/fixtures/petstore_spec.json +0 -0
  56. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/tests/test_auth.py +0 -0
  57. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/tests/test_cli_commands.py +1 -1
  58. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/tests/test_commands.py +0 -0
  59. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/tests/test_coverage_gaps.py +0 -0
  60. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/tests/test_integration.py +0 -0
  61. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/tests/test_oidc.py +0 -0
  62. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/tests/test_profile_management.py +0 -0
  63. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/tests/test_run_command.py +0 -0
  64. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/tests/test_spec_parsing.py +0 -0
  65. {openapi_cli4ai-0.6.0 → openapi_cli4ai-0.7.0}/tests/test_sse_streaming.py +0 -0
@@ -17,7 +17,7 @@ jobs:
17
17
  python-version: ["3.11", "3.12", "3.13"]
18
18
  steps:
19
19
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
20
- - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
20
+ - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
21
21
  with:
22
22
  enable-cache: true
23
23
  - name: Set up Python ${{ matrix.python-version }}
@@ -31,7 +31,7 @@ jobs:
31
31
  runs-on: ubuntu-latest
32
32
  steps:
33
33
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
34
- - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
34
+ - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
35
35
  - name: Sync dependencies
36
36
  run: uv sync --locked
37
37
  - name: Ruff check
@@ -43,7 +43,7 @@ jobs:
43
43
  runs-on: ubuntu-latest
44
44
  steps:
45
45
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
46
- - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
46
+ - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
47
47
  - name: Sync dependencies
48
48
  run: uv sync --locked
49
49
  - name: Run mypy
@@ -17,7 +17,7 @@ jobs:
17
17
  runs-on: ubuntu-latest
18
18
  steps:
19
19
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
20
- - uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
20
+ - uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
21
21
  with:
22
22
  languages: python
23
- - uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
23
+ - uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
@@ -14,7 +14,7 @@ jobs:
14
14
  contents: read
15
15
  steps:
16
16
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
17
- - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
17
+ - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
18
18
  - name: Build sdist and wheel
19
19
  run: uv build
20
20
  - name: Upload artifacts
@@ -42,8 +42,8 @@ jobs:
42
42
  name: dist
43
43
  path: dist/
44
44
  - name: Generate attestations
45
- uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
45
+ uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
46
46
  with:
47
47
  subject-path: dist/*
48
48
  - name: Publish to PyPI
49
- uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1
49
+ uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
@@ -19,6 +19,8 @@ jobs:
19
19
  runs-on: ubuntu-latest
20
20
  steps:
21
21
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
22
- - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
22
+ - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
23
23
  - name: Validate catalog profiles
24
24
  run: uv run --locked openapi-cli4ai catalog validate --all
25
+ - name: Check profiles/index.json is current
26
+ run: uv run --locked openapi-cli4ai catalog index --check
@@ -4,6 +4,28 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/).
6
6
 
7
+ ## [0.7.0] - 2026-09-11
8
+
9
+ ### Added
10
+
11
+ - `--profile` is accepted after the subcommand (`openapi-cli4ai endpoints --profile x`); long form only, since `-p` is reused by subcommands (#52)
12
+ - `catalog install --dry-run` prints the exact profile file that would be written and changes nothing (#53)
13
+ - `catalog uninstall <name>` removes an installed catalog profile plus its cached spec and token, with confirm prompt and `--force` (#54)
14
+ - Drop-in profiles: one file per profile under `~/.openapi-cli4ai/profiles.d/<name>.toml`, read alongside `~/.openapi-cli4ai.toml` (a drop-in overrides a same-named entry). `catalog install` writes there. No migration needed (#56)
15
+ - Remote catalog index: `catalog list`/`search`/`show`/`install` merge `profiles/index.json` from `main` over the bundled catalog (3 s timeout, cached one hour, silent fallback to cache then bundled; every remote entry is re-validated). `OAC_CATALOG_OFFLINE=1` disables it. New `catalog index` command generates the committed index; CI checks it is current (#57)
16
+ - DNS TXT domain verification: a profile may set `domain_verified = true`; `catalog validate` resolves `_openapi-cli4ai.<domain>` and requires `github=<maintainer>`. Shown as a `✓ dns` badge, separate from the tier (#58)
17
+
18
+ ### Changed
19
+
20
+ - `cli.py` split into `config.py`, `catalog.py`, `validator.py` plus small state/UI modules; behavior unchanged (#55)
21
+ - Adopted ruff 0.16's default rule set; the rule selection is no longer implicit (#50, #51)
22
+ - Dependency updates: `typer` 0.27.2, `click` 8.5, `python-dotenv`, `mypy`, `types-PyYAML`, and the GitHub Actions group (#49, #50)
23
+
24
+ ### Fixed
25
+
26
+ - Rich swallowed TOML table headers such as `[profiles.x]` in `profile show` output; now rendered as plain text (#53)
27
+ - File paths in install output are never wrapped across lines (#56)
28
+
7
29
  ## [0.6.0] - 2026-07-31
8
30
 
9
31
  ### Added
@@ -16,12 +16,17 @@ Thanks for your interest in contributing!
16
16
  src/openapi_cli4ai/
17
17
  __init__.py # Package exports
18
18
  __main__.py # python -m support
19
- cli.py # All CLI code lives here
19
+ cli.py # Typer commands, HTTP/auth flows, spec fetching
20
+ config.py # Config file, cache dir, active-profile precedence
21
+ catalog.py # Bundled profile catalog: load, find, map to a profile
22
+ validator.py # Catalog profile validator (shared by `catalog validate` and CI)
23
+ _state.py # Runtime flags set by the root command (--verbose, --profile, ...)
24
+ _ui.py # Shared Rich consoles and the verbose logger
20
25
  openapi-cli4ai # Standalone shim (imports from package)
21
26
  tests/ # pytest tests
22
27
  ```
23
28
 
24
- The core code lives in `src/openapi_cli4ai/cli.py`. The standalone `openapi-cli4ai` script is a thin shim that imports from the package.
29
+ Commands live in `src/openapi_cli4ai/cli.py`; it imports the helpers it needs from the service modules, so tests can patch either the service module or the `cli` binding. The standalone `openapi-cli4ai` script is a thin shim that imports from the package.
25
30
 
26
31
  ## Testing
27
32
 
@@ -48,10 +53,16 @@ pytest tests/ -m "not integration" -v
48
53
  3. Keep the `description` factual. `source` must be the API's own developer or
49
54
  docs URL (same domain as `base_url`).
50
55
  4. Validate locally: `uv run openapi-cli4ai catalog validate profiles/community/<slug>.toml`
51
-
52
- CI runs `catalog validate --all` on every PR touching `profiles/`, checking the
53
- fields, a live spec fetch, ownership, and absence of inline secrets — errors
54
- appear inline on the PR. Profiles that are primarily promotional will be closed
56
+ 5. Regenerate the index and commit it: `uv run openapi-cli4ai catalog index`
57
+ 6. Optional: claim the domain-verified badge by setting `domain_verified = true`
58
+ and publishing a `_openapi-cli4ai.<domain>` TXT record naming you; see
59
+ "Domain verification" in [`profiles/README.md`](profiles/README.md).
60
+
61
+ CI runs `catalog validate --all` and `catalog index --check` on every PR touching
62
+ `profiles/`, checking the fields, a live spec fetch, ownership, absence of inline
63
+ secrets, any `domain_verified` claim against DNS, and that `profiles/index.json`
64
+ is current — errors appear inline on the PR. Once merged, the profile is visible to every installed CLI within an hour
65
+ via the remote index (see `profiles/README.md`, "Freshness"). Profiles that are primarily promotional will be closed
55
66
  without review.
56
67
 
57
68
  ## Reporting Issues
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: openapi-cli4ai
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: Turn any REST API with an OpenAPI spec into an AI-ready CLI
5
5
  Project-URL: Homepage, https://github.com/dbgorilla/openapi-cli4ai
6
6
  Project-URL: Repository, https://github.com/dbgorilla/openapi-cli4ai
@@ -237,6 +237,8 @@ openapi-cli4ai logout
237
237
 
238
238
  Profiles are stored in `~/.openapi-cli4ai.toml`. Secrets are referenced via environment variables — never stored in the config file.
239
239
 
240
+ Profiles can also live one-per-file under `~/.openapi-cli4ai/profiles.d/<name>.toml`, where the file holds the profile's keys directly (no `[profiles.<name>]` header) and the file name is the profile name. `catalog install` writes there so installing, upgrading and uninstalling a profile touch only that file. Both locations are read; a drop-in overrides a same-named entry in `~/.openapi-cli4ai.toml`. `active_profile` is only read from `~/.openapi-cli4ai.toml`.
241
+
240
242
  ```toml
241
243
  active_profile = "myapi"
242
244
 
@@ -208,6 +208,8 @@ openapi-cli4ai logout
208
208
 
209
209
  Profiles are stored in `~/.openapi-cli4ai.toml`. Secrets are referenced via environment variables — never stored in the config file.
210
210
 
211
+ Profiles can also live one-per-file under `~/.openapi-cli4ai/profiles.d/<name>.toml`, where the file holds the profile's keys directly (no `[profiles.<name>]` header) and the file name is the profile name. `catalog install` writes there so installing, upgrading and uninstalling a profile touch only that file. Both locations are read; a drop-in overrides a same-named entry in `~/.openapi-cli4ai.toml`. `active_profile` is only read from `~/.openapi-cli4ai.toml`.
212
+
211
213
  ```toml
212
214
  active_profile = "myapi"
213
215
 
@@ -3,7 +3,8 @@
3
3
  Ready-made **profiles** for public APIs. A profile is the small bit of config
4
4
  `openapi-cli4ai` needs to talk to an API: a base URL, where its OpenAPI spec
5
5
  lives, and how it authenticates. The catalog is bundled into the package, so
6
- these commands work offline:
6
+ these commands work offline; when online they also pick up profiles merged to
7
+ `main` since your release (see [Freshness](#freshness)):
7
8
 
8
9
  ```bash
9
10
  openapi-cli4ai catalog search cov # find profiles
@@ -26,12 +27,30 @@ a **tier**, shown next to every entry, not a gate on being listed.
26
27
  | **Verified** | `verified/` | A maintainer confirmed the spec loads and the auth flow works. |
27
28
  | **Community** | `community/` | Contributed via PR and passed automated validation. Not manually vetted. |
28
29
 
30
+ Independently of the tier, a profile can carry a **domain-verified** badge
31
+ (`✓ dns` in listings): the owner of the API's domain has published a DNS TXT
32
+ record naming the maintainer, so the listing is backed by domain control, not
33
+ just a self-declared `source`. See [Domain verification](#domain-verification).
34
+
29
35
  New submissions go to `community/`; a maintainer may promote a profile to
30
36
  `verified/` after checking it end to end. Installing a **community** profile
31
37
  prompts for confirmation (it shows the `base_url` your credentials would be
32
38
  sent to); **verified** profiles install without a prompt. Use `--yes` to skip
33
39
  the prompt in scripts.
34
40
 
41
+ ## Freshness
42
+
43
+ The bundled catalog is the offline baseline. On top of it, `catalog list`,
44
+ `search`, `show` and `install` fetch [`index.json`](index.json) from this
45
+ repository's `main` branch (3 s timeout, cached for an hour under
46
+ `~/.cache/openapi-cli4ai/`) and merge it in, so a profile is usable as soon as
47
+ its PR merges. Any failure falls back to the cached index, then to the bundled
48
+ copy, silently. Every remote entry is re-checked with the offline validator
49
+ before use; a bad one is skipped. Set `OAC_CATALOG_OFFLINE=1` to never fetch.
50
+
51
+ `index.json` is generated from the TOML files by `openapi-cli4ai catalog index`
52
+ and committed; CI fails a PR whose index is stale.
53
+
35
54
  ## Profile format
36
55
 
37
56
  One profile per file, named `<slug>.toml`:
@@ -45,6 +64,8 @@ source = "https://example.com/docs" # the API's official docs (same domain
45
64
  base_url = "https://api.example.com"
46
65
  openapi_url = "https://api.example.com/openapi.json" # or: openapi_path = "/openapi.json"
47
66
 
67
+ domain_verified = true # optional: see "Domain verification" below
68
+
48
69
  [auth]
49
70
  type = "api-key" # none | bearer | oidc | device | api-key | basic
50
71
  env_var = "EXAMPLE_API_KEY" # reference secrets by env var — never inline them
@@ -55,6 +76,22 @@ Auth field names match the CLI's runtime config; see
55
76
  [`../examples/profiles.toml.example`](../examples/profiles.toml.example) for
56
77
  each auth type.
57
78
 
79
+ ## Domain verification
80
+
81
+ To claim the `✓ dns` badge, set `domain_verified = true` in the profile and have
82
+ the API's domain owner publish this TXT record (registrable domain of
83
+ `base_url`, e.g. `api.acme.co.uk` → `acme.co.uk`):
84
+
85
+ ```
86
+ _openapi-cli4ai.acme.co.uk. IN TXT "github=<maintainer>"
87
+ ```
88
+
89
+ `<maintainer>` must equal the profile's `maintainer` field (case-insensitive).
90
+ `catalog validate` resolves the record and fails the PR if it is missing or
91
+ names someone else; the claim is re-checked on every profile change. Remove the
92
+ claim (or the record) to drop the badge. The lookup needs `dnspython`, which
93
+ `uv sync` installs for contributors; end users never resolve DNS.
94
+
58
95
  ## Contributing a profile
59
96
 
60
97
  1. Create `community/<slug>.toml` in the format above.
@@ -62,10 +99,12 @@ each auth type.
62
99
  3. Keep `description` factual. `source` must be the API's own developer/docs
63
100
  URL (same registrable domain as `base_url`).
64
101
 
65
- Validate before opening the PR — the CLI is the single source of truth:
102
+ Validate before opening the PR — the CLI is the single source of truth — and
103
+ regenerate the index:
66
104
 
67
105
  ```bash
68
106
  uv run openapi-cli4ai catalog validate profiles/community/<slug>.toml
107
+ uv run openapi-cli4ai catalog index
69
108
  ```
70
109
 
71
110
  CI runs `catalog validate --all` on every PR touching `profiles/`. It checks the
@@ -0,0 +1,33 @@
1
+ {
2
+ "profiles": [
3
+ {
4
+ "auth": {
5
+ "env_var": "XQUIK_API_KEY",
6
+ "header": "x-api-key",
7
+ "type": "api-key"
8
+ },
9
+ "base_url": "https://xquik.com",
10
+ "description": "Xquik REST API",
11
+ "maintainer": "kriptoburak",
12
+ "name": "xquik",
13
+ "openapi_url": "https://xquik.com/openapi.json",
14
+ "slug": "xquik",
15
+ "source": "https://xquik.com",
16
+ "tier": "community"
17
+ },
18
+ {
19
+ "auth": {
20
+ "type": "none"
21
+ },
22
+ "base_url": "https://petstore3.swagger.io/api/v3",
23
+ "description": "Swagger Petstore \u2014 the OpenAPI 3 sample API",
24
+ "maintainer": "dbgorilla",
25
+ "name": "petstore",
26
+ "openapi_url": "https://petstore3.swagger.io/api/v3/openapi.json",
27
+ "slug": "petstore",
28
+ "source": "https://petstore3.swagger.io",
29
+ "tier": "verified"
30
+ }
31
+ ],
32
+ "version": 1
33
+ }
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "openapi-cli4ai"
7
- version = "0.6.0"
7
+ version = "0.7.0"
8
8
  description = "Turn any REST API with an OpenAPI spec into an AI-ready CLI"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -46,12 +46,14 @@ dev = [
46
46
  "pytest>=8",
47
47
  "pytest-cov>=5",
48
48
  "click>=8,<9",
49
- # ruff pinned <0.16: 0.16 stabilized RUF059 (and others) that flag ~450
50
- # existing lines. CI runs the locked ruff (uv run) instead of uvx-latest so
51
- # lint is deterministic; a future bump comes via Dependabot and is gated by CI.
52
- "ruff>=0.15,<0.16",
49
+ # CI runs the locked ruff (uv run) instead of uvx-latest so lint is
50
+ # deterministic; a bump comes via Dependabot and is gated by CI.
51
+ "ruff>=0.15,<0.17",
53
52
  "mypy>=1.10,<3",
54
53
  "types-PyYAML>=6,<7",
54
+ # DNS TXT domain verification in `catalog validate` (CI + contributors);
55
+ # end users never resolve DNS, so this is not a runtime dependency.
56
+ "dnspython>=2.4,<3",
55
57
  ]
56
58
 
57
59
  [tool.hatch.build.targets.wheel]
@@ -66,6 +68,15 @@ packages = ["src/openapi_cli4ai"]
66
68
  line-length = 120
67
69
  target-version = "py311"
68
70
 
71
+ [tool.ruff.lint]
72
+ # Rule set = ruff's defaults for the locked version. Drift is guarded by the
73
+ # version cap on the dev dependency plus uv.lock; a ruff bump arrives via
74
+ # Dependabot and is gated by CI, so new rules are adopted deliberately.
75
+ # RUF059 (unused unpacked variable) is ignored in tests: fixtures return
76
+ # tuples like `mod, tmp_path, cache_dir = tmp_config` and unpacking the
77
+ # full shape is clearer than `_`-prefixing whichever half a test skips.
78
+ per-file-ignores = { "tests/**" = ["RUF059"] }
79
+
69
80
  [tool.pytest.ini_options]
70
81
  testpaths = ["tests"]
71
82
  markers = ["integration: live API tests (may be slow)"]
@@ -3,4 +3,4 @@
3
3
  from openapi_cli4ai.cli import VERSION, app
4
4
 
5
5
  __version__ = VERSION
6
- __all__ = ["app", "VERSION", "__version__"]
6
+ __all__ = ["VERSION", "__version__", "app"]
@@ -0,0 +1,22 @@
1
+ """Runtime settings set once by the root command and read by every layer.
2
+
3
+ Module globals on purpose: the CLI is a single process handling one command,
4
+ and tests set these directly (``_state._verbose_mode = True``).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ _insecure_mode = False
10
+ _verbose_mode = False
11
+ _timeout_seconds = 60.0
12
+ _max_retries = 0
13
+ _profile_override: str | None = None
14
+
15
+
16
+ def set_insecure_mode(insecure: bool) -> None:
17
+ global _insecure_mode
18
+ _insecure_mode = insecure
19
+
20
+
21
+ def get_verify_ssl() -> bool:
22
+ return not _insecure_mode
@@ -0,0 +1,16 @@
1
+ """Shared Rich consoles and the verbose logger."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.console import Console
6
+
7
+ from openapi_cli4ai import _state
8
+
9
+ console = Console()
10
+ err_console = Console(stderr=True)
11
+
12
+
13
+ def _verbose(msg: str) -> None:
14
+ """Print a verbose message to stderr if verbose mode is enabled."""
15
+ if _state._verbose_mode:
16
+ err_console.print(f"[dim]> {msg}[/dim]")
@@ -0,0 +1,247 @@
1
+ """Profile catalog: ready-made API profiles (load, search, map to a runtime profile).
2
+
3
+ Two sources, merged: the catalog bundled in the wheel (always available, so
4
+ every command works offline) and a remote index published from the main
5
+ branch, so a newly merged profile is visible before the next release. The
6
+ remote index is cached for CATALOG_INDEX_TTL seconds and any failure falls
7
+ back to the cache, then to the bundled copy.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import importlib.resources
13
+ import json
14
+ import os
15
+ import re
16
+ import time
17
+ import tomllib
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ import httpx
22
+ from rich.table import Table
23
+
24
+ from openapi_cli4ai import config
25
+ from openapi_cli4ai._ui import _verbose as _log
26
+ from openapi_cli4ai._ui import console
27
+
28
+ CATALOG_TIERS = ("verified", "community")
29
+ _CATALOG_META_FIELDS = ("name", "description", "maintainer", "source", "domain_verified")
30
+
31
+ # Remote index: generated by `catalog index` and committed to profiles/index.json.
32
+ CATALOG_INDEX_URL = "https://raw.githubusercontent.com/dbgorilla/openapi-cli4ai/main/profiles/index.json"
33
+ CATALOG_INDEX_TTL = 3600 # seconds
34
+ CATALOG_INDEX_VERSION = 1
35
+ _CATALOG_INDEX_MAX_BYTES = 1024 * 1024
36
+ _CATALOG_INDEX_TIMEOUT = httpx.Timeout(3.0, connect=2.0) # never make an offline user wait
37
+ # Set to any value to skip the remote index entirely (CI, air-gapped, tests).
38
+ CATALOG_OFFLINE_ENV = "OAC_CATALOG_OFFLINE"
39
+ _SLUG_RE = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?")
40
+
41
+
42
+ def _catalog_root() -> Any:
43
+ """Locate the catalog: bundled in the wheel, else profiles/ in a source checkout."""
44
+ try:
45
+ bundled = importlib.resources.files("openapi_cli4ai") / "_catalog"
46
+ if bundled.is_dir():
47
+ return bundled
48
+ except (ModuleNotFoundError, FileNotFoundError, AttributeError, TypeError):
49
+ pass
50
+ dev = Path(__file__).resolve().parents[2] / "profiles"
51
+ return dev if dev.is_dir() else None
52
+
53
+
54
+ def _load_catalog(remote: bool = True) -> list[dict]:
55
+ """Return every catalog profile as a dict with _tier and _slug attached.
56
+
57
+ Bundled entries first, then the remote index merged on top (a remote entry
58
+ replaces a bundled one with the same slug). `remote=False` returns only the
59
+ bundled copy; `catalog validate --all` and `catalog index` use that so they
60
+ describe the files in this checkout, not whatever main currently publishes.
61
+ """
62
+ entries = _load_bundled_catalog()
63
+ if not remote:
64
+ return entries
65
+ remote_entries = _fetch_remote_index()
66
+ if not remote_entries:
67
+ return entries
68
+ merged = {e["_slug"]: e for e in entries}
69
+ for entry in remote_entries:
70
+ merged[entry["_slug"]] = entry
71
+ return list(merged.values())
72
+
73
+
74
+ def _load_bundled_catalog() -> list[dict]:
75
+ """Read the catalog shipped in the wheel (or profiles/ in a source checkout)."""
76
+ root = _catalog_root()
77
+ entries: list[dict] = []
78
+ if root is None:
79
+ return entries
80
+ for tier in CATALOG_TIERS:
81
+ tier_dir = root / tier
82
+ if not tier_dir.is_dir():
83
+ continue
84
+ for item in sorted(tier_dir.iterdir(), key=lambda p: p.name):
85
+ if not item.name.endswith(".toml"):
86
+ continue
87
+ try:
88
+ entry = tomllib.loads(item.read_text(encoding="utf-8"))
89
+ except (OSError, tomllib.TOMLDecodeError):
90
+ continue
91
+ entry["_tier"] = tier
92
+ entry["_slug"] = item.name[: -len(".toml")]
93
+ entries.append(entry)
94
+ return entries
95
+
96
+
97
+ # ── Remote index ──────────────────────────────────────────────────────────────
98
+ def build_index(entries: list[dict]) -> dict:
99
+ """Serialisable, deterministic index of catalog entries (no timestamps, sorted)."""
100
+ profiles = []
101
+ for entry in sorted(entries, key=lambda e: (e.get("_tier", ""), e.get("_slug", ""))):
102
+ record = {k: v for k, v in entry.items() if not k.startswith("_")}
103
+ record["tier"] = entry.get("_tier")
104
+ record["slug"] = entry.get("_slug")
105
+ profiles.append(record)
106
+ return {"version": CATALOG_INDEX_VERSION, "profiles": profiles}
107
+
108
+
109
+ def index_json(entries: list[dict]) -> str:
110
+ return json.dumps(build_index(entries), indent=2, sort_keys=True) + "\n"
111
+
112
+
113
+ def _index_to_entries(index: Any) -> list[dict] | None:
114
+ """Turn a parsed index into catalog entries. None if the document is malformed.
115
+
116
+ Every entry must pass the offline validator; a bad one is skipped, not fatal,
117
+ so one broken profile on main cannot blank the catalog for everyone.
118
+ """
119
+ # Local import: validator imports this module for _catalog_to_profile.
120
+ from openapi_cli4ai.validator import _validate_catalog_entry
121
+
122
+ if not isinstance(index, dict) or index.get("version") != CATALOG_INDEX_VERSION:
123
+ return None
124
+ profiles = index.get("profiles")
125
+ if not isinstance(profiles, list):
126
+ return None
127
+ entries: list[dict] = []
128
+ for record in profiles:
129
+ if not isinstance(record, dict):
130
+ continue
131
+ entry = dict(record)
132
+ tier = entry.pop("tier", None)
133
+ slug = entry.pop("slug", None)
134
+ if tier not in CATALOG_TIERS or not isinstance(slug, str) or not _SLUG_RE.fullmatch(slug):
135
+ continue
136
+ entry["_tier"] = tier
137
+ entry["_slug"] = slug
138
+ errors, _warnings = _validate_catalog_entry(entry, check_spec=False)
139
+ if errors:
140
+ _log(f"catalog index: skipping '{slug}': {errors[0]}")
141
+ continue
142
+ entries.append(entry)
143
+ return entries
144
+
145
+
146
+ def _index_cache_path() -> Path:
147
+ return config.CACHE_DIR / "catalog_index.json"
148
+
149
+
150
+ def _download_index() -> Any:
151
+ """GET the remote index. Raises httpx.HTTPError or ValueError on any problem."""
152
+ with httpx.Client(timeout=_CATALOG_INDEX_TIMEOUT, follow_redirects=False) as client:
153
+ resp = client.get(CATALOG_INDEX_URL)
154
+ resp.raise_for_status()
155
+ if len(resp.content) > _CATALOG_INDEX_MAX_BYTES:
156
+ raise ValueError(f"index exceeds {_CATALOG_INDEX_MAX_BYTES // 1024}KB")
157
+ return resp.json()
158
+
159
+
160
+ def _read_index_cache() -> tuple[float, Any] | None:
161
+ try:
162
+ cached = json.loads(_index_cache_path().read_text(encoding="utf-8"))
163
+ return float(cached["fetched_at"]), cached["index"]
164
+ except (OSError, ValueError, KeyError, TypeError):
165
+ return None
166
+
167
+
168
+ def _fetch_remote_index() -> list[dict] | None:
169
+ """Remote catalog entries, via the TTL cache. None means: use the bundled copy only."""
170
+ if os.environ.get(CATALOG_OFFLINE_ENV):
171
+ return None
172
+ cached = _read_index_cache()
173
+ if cached is not None and time.time() - cached[0] < CATALOG_INDEX_TTL:
174
+ return _index_to_entries(cached[1])
175
+ try:
176
+ index = _download_index()
177
+ except (httpx.HTTPError, ValueError) as exc:
178
+ _log(f"catalog index: fetch failed ({exc}); using {'cached' if cached else 'bundled'} catalog")
179
+ return _index_to_entries(cached[1]) if cached else None
180
+ entries = _index_to_entries(index)
181
+ if entries is None:
182
+ _log("catalog index: malformed document; using bundled catalog")
183
+ return None
184
+ try:
185
+ config.ensure_dirs()
186
+ config._atomic_write(_index_cache_path(), json.dumps({"fetched_at": time.time(), "index": index}))
187
+ except OSError as exc:
188
+ _log(f"catalog index: could not write cache: {exc}")
189
+ return entries
190
+
191
+
192
+ def _catalog_find(name: str) -> dict | None:
193
+ for entry in _load_catalog():
194
+ if name in (entry.get("_slug"), entry.get("name")):
195
+ return entry
196
+ return None
197
+
198
+
199
+ def _catalog_to_profile(entry: dict) -> dict:
200
+ """Map a catalog entry to a runtime profile (drop catalog-only metadata)."""
201
+ profile = {k: v for k, v in entry.items() if k not in _CATALOG_META_FIELDS and not k.startswith("_")}
202
+ profile["verify_ssl"] = True
203
+ return profile
204
+
205
+
206
+ def _auth_env_vars(auth: dict) -> list[str]:
207
+ """Environment variables the user must set for this auth config."""
208
+ keys = {
209
+ "api-key": ("env_var",),
210
+ "bearer": ("token_env_var",),
211
+ "basic": ("username_env_var", "password_env_var"),
212
+ }.get(auth.get("type", "none"), ())
213
+ return [auth[k] for k in keys if auth.get(k)]
214
+
215
+
216
+ def _auth_uses_login(auth: dict) -> bool:
217
+ kind = auth.get("type", "none")
218
+ return kind in ("oidc", "device") or (kind == "bearer" and bool(auth.get("token_endpoint")))
219
+
220
+
221
+ def _auth_summary(auth: dict) -> str:
222
+ kind = auth.get("type", "none")
223
+ if _auth_uses_login(auth):
224
+ return f"{kind} → run 'login'"
225
+ env = _auth_env_vars(auth)
226
+ return f"{kind} → set {', '.join(env)}" if env else kind
227
+
228
+
229
+ def _render_catalog(entries: list[dict], title: str) -> None:
230
+ if not entries:
231
+ console.print("[dim]The catalog is empty.[/dim]")
232
+ return
233
+ table = Table(title=f"{title} ({len(entries)})")
234
+ table.add_column("Name", style="cyan")
235
+ table.add_column("Tier")
236
+ table.add_column("Domain")
237
+ table.add_column("Description", style="green")
238
+ for entry in sorted(entries, key=lambda e: (e.get("_tier", ""), e.get("_slug", ""))):
239
+ tier = entry.get("_tier", "community")
240
+ style = "bold green" if tier == "verified" else "yellow"
241
+ domain = "[bold blue]✓ dns[/bold blue]" if entry.get("domain_verified") is True else ""
242
+ table.add_row(entry.get("_slug", "?"), f"[{style}]{tier}[/{style}]", domain, str(entry.get("description", "")))
243
+ console.print(table)
244
+ console.print(
245
+ "[dim]Install one with: openapi-cli4ai catalog install <name>. "
246
+ "✓ dns = the API's domain owner published a DNS TXT record naming the maintainer.[/dim]"
247
+ )