loxo-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.
Files changed (59) hide show
  1. loxo_cli-0.1.0/.github/workflows/ci.yml +21 -0
  2. loxo_cli-0.1.0/.github/workflows/publish.yml +17 -0
  3. loxo_cli-0.1.0/.gitignore +8 -0
  4. loxo_cli-0.1.0/CHANGELOG.md +17 -0
  5. loxo_cli-0.1.0/LICENSE +21 -0
  6. loxo_cli-0.1.0/PKG-INFO +135 -0
  7. loxo_cli-0.1.0/README.md +120 -0
  8. loxo_cli-0.1.0/pyproject.toml +56 -0
  9. loxo_cli-0.1.0/src/loxo_cli/__init__.py +1 -0
  10. loxo_cli-0.1.0/src/loxo_cli/__main__.py +132 -0
  11. loxo_cli-0.1.0/src/loxo_cli/client.py +88 -0
  12. loxo_cli-0.1.0/src/loxo_cli/commands/__init__.py +0 -0
  13. loxo_cli-0.1.0/src/loxo_cli/commands/_app.py +28 -0
  14. loxo_cli-0.1.0/src/loxo_cli/commands/_helpers.py +48 -0
  15. loxo_cli-0.1.0/src/loxo_cli/commands/activities.py +71 -0
  16. loxo_cli-0.1.0/src/loxo_cli/commands/api.py +69 -0
  17. loxo_cli-0.1.0/src/loxo_cli/commands/candidates.py +87 -0
  18. loxo_cli-0.1.0/src/loxo_cli/commands/companies.py +100 -0
  19. loxo_cli-0.1.0/src/loxo_cli/commands/configure.py +56 -0
  20. loxo_cli-0.1.0/src/loxo_cli/commands/deals.py +101 -0
  21. loxo_cli-0.1.0/src/loxo_cli/commands/jobs.py +76 -0
  22. loxo_cli-0.1.0/src/loxo_cli/commands/people.py +91 -0
  23. loxo_cli-0.1.0/src/loxo_cli/commands/ref.py +60 -0
  24. loxo_cli-0.1.0/src/loxo_cli/commands/webhooks.py +98 -0
  25. loxo_cli-0.1.0/src/loxo_cli/config.py +140 -0
  26. loxo_cli-0.1.0/src/loxo_cli/errors.py +65 -0
  27. loxo_cli-0.1.0/src/loxo_cli/models/__init__.py +20 -0
  28. loxo_cli-0.1.0/src/loxo_cli/models/base.py +13 -0
  29. loxo_cli-0.1.0/src/loxo_cli/models/candidate.py +11 -0
  30. loxo_cli-0.1.0/src/loxo_cli/models/company.py +11 -0
  31. loxo_cli-0.1.0/src/loxo_cli/models/deal.py +11 -0
  32. loxo_cli-0.1.0/src/loxo_cli/models/job.py +11 -0
  33. loxo_cli-0.1.0/src/loxo_cli/models/person.py +14 -0
  34. loxo_cli-0.1.0/src/loxo_cli/models/reference.py +10 -0
  35. loxo_cli-0.1.0/src/loxo_cli/models/webhook.py +12 -0
  36. loxo_cli-0.1.0/src/loxo_cli/output.py +98 -0
  37. loxo_cli-0.1.0/src/loxo_cli/pagination.py +90 -0
  38. loxo_cli-0.1.0/tests/conftest.py +0 -0
  39. loxo_cli-0.1.0/tests/test_app_state.py +36 -0
  40. loxo_cli-0.1.0/tests/test_client.py +75 -0
  41. loxo_cli-0.1.0/tests/test_cmd_activities.py +51 -0
  42. loxo_cli-0.1.0/tests/test_cmd_api.py +74 -0
  43. loxo_cli-0.1.0/tests/test_cmd_candidates.py +55 -0
  44. loxo_cli-0.1.0/tests/test_cmd_companies.py +57 -0
  45. loxo_cli-0.1.0/tests/test_cmd_configure.py +55 -0
  46. loxo_cli-0.1.0/tests/test_cmd_deals.py +48 -0
  47. loxo_cli-0.1.0/tests/test_cmd_jobs.py +58 -0
  48. loxo_cli-0.1.0/tests/test_cmd_people.py +67 -0
  49. loxo_cli-0.1.0/tests/test_cmd_ref.py +43 -0
  50. loxo_cli-0.1.0/tests/test_cmd_webhooks.py +80 -0
  51. loxo_cli-0.1.0/tests/test_config.py +97 -0
  52. loxo_cli-0.1.0/tests/test_error_exit_codes.py +52 -0
  53. loxo_cli-0.1.0/tests/test_errors.py +36 -0
  54. loxo_cli-0.1.0/tests/test_helpers.py +56 -0
  55. loxo_cli-0.1.0/tests/test_models.py +38 -0
  56. loxo_cli-0.1.0/tests/test_output.py +52 -0
  57. loxo_cli-0.1.0/tests/test_pagination.py +73 -0
  58. loxo_cli-0.1.0/tests/test_smoke.py +17 -0
  59. loxo_cli-0.1.0/uv.lock +714 -0
@@ -0,0 +1,21 @@
1
+ name: CI
2
+ on:
3
+ push:
4
+ branches: [main]
5
+ pull_request:
6
+ jobs:
7
+ test:
8
+ runs-on: ubuntu-latest
9
+ strategy:
10
+ matrix:
11
+ python-version: ["3.11", "3.12", "3.13"]
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - uses: astral-sh/setup-uv@v5
15
+ with:
16
+ python-version: ${{ matrix.python-version }}
17
+ - run: uv sync --all-extras --dev
18
+ - run: uv run ruff check src tests
19
+ - run: uv run black --check src tests
20
+ - run: uv run mypy
21
+ - run: uv run pytest -q
@@ -0,0 +1,17 @@
1
+ name: Publish
2
+ on:
3
+ push:
4
+ tags: ["v*"]
5
+ jobs:
6
+ publish:
7
+ runs-on: ubuntu-latest
8
+ environment: pypi
9
+ permissions:
10
+ id-token: write # trusted publishing (OIDC)
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: astral-sh/setup-uv@v5
14
+ with:
15
+ python-version: "3.12"
16
+ - run: uv build
17
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ dist/
5
+ .pytest_cache/
6
+ .ruff_cache/
7
+ .mypy_cache/
8
+ docs/
@@ -0,0 +1,17 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ### Added
6
+
7
+ - Initial release of `loxo-cli`.
8
+ - Credential profiles via `loxo configure` (flags > env > `~/.config/loxo/config.toml`),
9
+ including `api_key_cmd` for pulling the key from a secrets manager.
10
+ - Typed command groups: `people`, `jobs`, `companies`, `deals`, `candidates`,
11
+ `activities`, `webhooks`, and `ref` (reference data and custom fields).
12
+ - Generic `loxo api METHOD PATH` escape hatch for any endpoint, with `--all`
13
+ auto-pagination.
14
+ - Scheme-aware pagination (`scroll_id`, `page`, `after_id`) with `--all`.
15
+ - TTY tables plus `--json`/`--jq` for scripting; tolerant models that preserve
16
+ custom/dynamic fields.
17
+ - Documented exit codes (auth, not-found, rate-limited, server, timeout/network).
loxo_cli-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andrew Mitchell
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,135 @@
1
+ Metadata-Version: 2.4
2
+ Name: loxo-cli
3
+ Version: 0.1.0
4
+ Summary: Unofficial command-line interface for the Loxo recruiting ATS/CRM API
5
+ Project-URL: Homepage, https://github.com/alphaomegateam/loxo-cli
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.11
9
+ Requires-Dist: click>=8
10
+ Requires-Dist: httpx>=0.27
11
+ Requires-Dist: pydantic>=2
12
+ Requires-Dist: rich>=13
13
+ Requires-Dist: typer>=0.12
14
+ Description-Content-Type: text/markdown
15
+
16
+ # loxo-cli
17
+
18
+ A fast, ergonomic command-line interface for the [Loxo](https://loxo.co) recruiting
19
+ ATS/CRM REST API. It offers typed subcommands for the common resources (people, jobs,
20
+ companies, deals, candidates, activities, webhooks, reference data) plus a generic
21
+ `loxo api` escape hatch that can call any endpoint. Output is human-friendly tables on a
22
+ terminal and clean JSON when piped, so it fits both interactive use and scripts.
23
+
24
+ Unofficial — not affiliated with Loxo, Inc.
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ uvx loxo-cli # run without installing
30
+ pipx install loxo-cli # or install as a user tool
31
+ ```
32
+
33
+ ## Quickstart
34
+
35
+ ```bash
36
+ loxo configure # set up a profile
37
+ loxo people list --query "engineer" # human table
38
+ loxo people list --json | jq '.' # JSON for scripts
39
+ loxo api GET jobs/123 # raw escape hatch
40
+ ```
41
+
42
+ ## Configuration
43
+
44
+ Credentials resolve with the precedence **flags > environment > config file**.
45
+
46
+ Environment variables:
47
+
48
+ | Variable | Meaning |
49
+ |---|---|
50
+ | `LOXO_API_KEY` | API bearer token |
51
+ | `LOXO_API_SLUG` | Agency slug (the `{slug}` in every request URL) |
52
+ | `LOXO_BASE_URL` | API base URL (default `https://app.loxo.co/api`) |
53
+ | `LOXO_PROFILE` | Default profile name to use |
54
+
55
+ The config file lives at `~/.config/loxo/config.toml` (or `$XDG_CONFIG_HOME/loxo/config.toml`)
56
+ and is written with `0600` permissions. Example:
57
+
58
+ ```toml
59
+ default_profile = "prod"
60
+
61
+ [profile.prod]
62
+ slug = "acme"
63
+ base_url = "https://app.loxo.co/api"
64
+ api_key = "your-token"
65
+
66
+ [profile.staging]
67
+ slug = "acme-staging"
68
+ # Pull the key from a secrets manager instead of storing it in plaintext:
69
+ api_key_cmd = "op read op://Private/loxo-staging/credential"
70
+ ```
71
+
72
+ `api_key_cmd` is run on demand and its stdout is used as the key, so the secret never has
73
+ to live in the file. The key is never printed by `loxo configure list`, logged, or shown in
74
+ `--verbose` output.
75
+
76
+ ## Commands
77
+
78
+ | Group | What it does |
79
+ |---|---|
80
+ | `people` | List/search, get, create, update people |
81
+ | `jobs` | List, get, create, update jobs |
82
+ | `companies` | List/search, get, create, update companies |
83
+ | `deals` | List, get, create, update deals |
84
+ | `candidates` | List/get/add/update candidates under a job |
85
+ | `activities` | List and add person events (activities) |
86
+ | `webhooks` | Full CRUD for webhooks (with enum validation) |
87
+ | `ref` | Reference lookups: job/activity/source/person types, lists, custom fields, hierarchies |
88
+ | `api` | Generic escape hatch — call any endpoint directly |
89
+ | `configure` | Create and list credential profiles |
90
+
91
+ Custom (dynamic) fields are supported on writes via repeatable `--field key=value`
92
+ (use `key[]=value` to force a list, e.g. hierarchy fields). Discover valid keys with
93
+ `loxo ref custom-fields`.
94
+
95
+ ## Output
96
+
97
+ On a terminal, list and object results render as Rich tables. Pipe the command or pass
98
+ `--json` to get machine-readable JSON; `--jq '<path>'` applies a small built-in selector
99
+ (e.g. `--jq '.[].id'`) without needing the `jq` binary.
100
+
101
+ ## Exit codes
102
+
103
+ | Code | Meaning |
104
+ |---|---|
105
+ | 0 | Success |
106
+ | 1 | Generic error |
107
+ | 2 | Usage error (bad flags/arguments) |
108
+ | 3 | Authentication/authorization failure (401/403) |
109
+ | 4 | Not found (404) |
110
+ | 5 | Rate limited (429) |
111
+ | 6 | Server error (5xx) |
112
+ | 7 | Timeout or network failure |
113
+
114
+ ## Pagination
115
+
116
+ Loxo paginates differently per endpoint: cursor (`scroll_id`), offset (`page`), and keyset
117
+ (`after_id`). `loxo-cli` detects and handles all three. List commands fetch a single page by
118
+ default; pass `--all` to transparently walk every page. The generic `loxo api ... --all`
119
+ auto-detects the scheme (or force it with `--paginate scroll_id|page|after_id`).
120
+
121
+ ## Contributing
122
+
123
+ ```bash
124
+ uv sync # install dependencies
125
+ uv run pytest # run the test suite (HTTP is mocked; no live calls)
126
+ uv run ruff check src tests
127
+ uv run black --check src tests
128
+ uv run mypy
129
+ ```
130
+
131
+ Commits follow [Conventional Commits](https://www.conventionalcommits.org/).
132
+
133
+ ## License
134
+
135
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,120 @@
1
+ # loxo-cli
2
+
3
+ A fast, ergonomic command-line interface for the [Loxo](https://loxo.co) recruiting
4
+ ATS/CRM REST API. It offers typed subcommands for the common resources (people, jobs,
5
+ companies, deals, candidates, activities, webhooks, reference data) plus a generic
6
+ `loxo api` escape hatch that can call any endpoint. Output is human-friendly tables on a
7
+ terminal and clean JSON when piped, so it fits both interactive use and scripts.
8
+
9
+ Unofficial — not affiliated with Loxo, Inc.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ uvx loxo-cli # run without installing
15
+ pipx install loxo-cli # or install as a user tool
16
+ ```
17
+
18
+ ## Quickstart
19
+
20
+ ```bash
21
+ loxo configure # set up a profile
22
+ loxo people list --query "engineer" # human table
23
+ loxo people list --json | jq '.' # JSON for scripts
24
+ loxo api GET jobs/123 # raw escape hatch
25
+ ```
26
+
27
+ ## Configuration
28
+
29
+ Credentials resolve with the precedence **flags > environment > config file**.
30
+
31
+ Environment variables:
32
+
33
+ | Variable | Meaning |
34
+ |---|---|
35
+ | `LOXO_API_KEY` | API bearer token |
36
+ | `LOXO_API_SLUG` | Agency slug (the `{slug}` in every request URL) |
37
+ | `LOXO_BASE_URL` | API base URL (default `https://app.loxo.co/api`) |
38
+ | `LOXO_PROFILE` | Default profile name to use |
39
+
40
+ The config file lives at `~/.config/loxo/config.toml` (or `$XDG_CONFIG_HOME/loxo/config.toml`)
41
+ and is written with `0600` permissions. Example:
42
+
43
+ ```toml
44
+ default_profile = "prod"
45
+
46
+ [profile.prod]
47
+ slug = "acme"
48
+ base_url = "https://app.loxo.co/api"
49
+ api_key = "your-token"
50
+
51
+ [profile.staging]
52
+ slug = "acme-staging"
53
+ # Pull the key from a secrets manager instead of storing it in plaintext:
54
+ api_key_cmd = "op read op://Private/loxo-staging/credential"
55
+ ```
56
+
57
+ `api_key_cmd` is run on demand and its stdout is used as the key, so the secret never has
58
+ to live in the file. The key is never printed by `loxo configure list`, logged, or shown in
59
+ `--verbose` output.
60
+
61
+ ## Commands
62
+
63
+ | Group | What it does |
64
+ |---|---|
65
+ | `people` | List/search, get, create, update people |
66
+ | `jobs` | List, get, create, update jobs |
67
+ | `companies` | List/search, get, create, update companies |
68
+ | `deals` | List, get, create, update deals |
69
+ | `candidates` | List/get/add/update candidates under a job |
70
+ | `activities` | List and add person events (activities) |
71
+ | `webhooks` | Full CRUD for webhooks (with enum validation) |
72
+ | `ref` | Reference lookups: job/activity/source/person types, lists, custom fields, hierarchies |
73
+ | `api` | Generic escape hatch — call any endpoint directly |
74
+ | `configure` | Create and list credential profiles |
75
+
76
+ Custom (dynamic) fields are supported on writes via repeatable `--field key=value`
77
+ (use `key[]=value` to force a list, e.g. hierarchy fields). Discover valid keys with
78
+ `loxo ref custom-fields`.
79
+
80
+ ## Output
81
+
82
+ On a terminal, list and object results render as Rich tables. Pipe the command or pass
83
+ `--json` to get machine-readable JSON; `--jq '<path>'` applies a small built-in selector
84
+ (e.g. `--jq '.[].id'`) without needing the `jq` binary.
85
+
86
+ ## Exit codes
87
+
88
+ | Code | Meaning |
89
+ |---|---|
90
+ | 0 | Success |
91
+ | 1 | Generic error |
92
+ | 2 | Usage error (bad flags/arguments) |
93
+ | 3 | Authentication/authorization failure (401/403) |
94
+ | 4 | Not found (404) |
95
+ | 5 | Rate limited (429) |
96
+ | 6 | Server error (5xx) |
97
+ | 7 | Timeout or network failure |
98
+
99
+ ## Pagination
100
+
101
+ Loxo paginates differently per endpoint: cursor (`scroll_id`), offset (`page`), and keyset
102
+ (`after_id`). `loxo-cli` detects and handles all three. List commands fetch a single page by
103
+ default; pass `--all` to transparently walk every page. The generic `loxo api ... --all`
104
+ auto-detects the scheme (or force it with `--paginate scroll_id|page|after_id`).
105
+
106
+ ## Contributing
107
+
108
+ ```bash
109
+ uv sync # install dependencies
110
+ uv run pytest # run the test suite (HTTP is mocked; no live calls)
111
+ uv run ruff check src tests
112
+ uv run black --check src tests
113
+ uv run mypy
114
+ ```
115
+
116
+ Commits follow [Conventional Commits](https://www.conventionalcommits.org/).
117
+
118
+ ## License
119
+
120
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,56 @@
1
+ [project]
2
+ name = "loxo-cli"
3
+ version = "0.1.0"
4
+ description = "Unofficial command-line interface for the Loxo recruiting ATS/CRM API"
5
+ readme = "README.md"
6
+ license = { text = "MIT" }
7
+ requires-python = ">=3.11"
8
+ dependencies = [
9
+ "typer>=0.12",
10
+ "click>=8", # imported directly in errors.py (ClickException base); declare it explicitly
11
+ "httpx>=0.27",
12
+ "pydantic>=2",
13
+ "rich>=13",
14
+ ]
15
+
16
+ [project.scripts]
17
+ loxo = "loxo_cli.__main__:app"
18
+
19
+ [project.urls]
20
+ Homepage = "https://github.com/alphaomegateam/loxo-cli"
21
+
22
+ [dependency-groups]
23
+ dev = [
24
+ "pytest>=8",
25
+ "pytest-mock>=3.14",
26
+ "respx>=0.21",
27
+ "ruff>=0.6",
28
+ "black>=24",
29
+ "mypy>=1.11",
30
+ ]
31
+
32
+ [build-system]
33
+ requires = ["hatchling"]
34
+ build-backend = "hatchling.build"
35
+
36
+ [tool.hatch.build.targets.wheel]
37
+ packages = ["src/loxo_cli"]
38
+
39
+ [tool.pytest.ini_options]
40
+ pythonpath = ["src"]
41
+ addopts = "-ra"
42
+
43
+ [tool.ruff]
44
+ line-length = 100
45
+ src = ["src", "tests"]
46
+
47
+ [tool.black]
48
+ line-length = 100
49
+ target-version = ["py311"]
50
+
51
+ [tool.mypy]
52
+ python_version = "3.11"
53
+ mypy_path = "src"
54
+ packages = ["loxo_cli"]
55
+ explicit_package_bases = true
56
+ ignore_missing_imports = true
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,132 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ from typing import Any, Optional
6
+
7
+ import typer
8
+ from rich.console import Console
9
+
10
+ from loxo_cli import __version__
11
+ from loxo_cli.client import LoxoClient, build_client
12
+ from loxo_cli.commands._app import LoxoGroup
13
+ from loxo_cli.config import LoxoSettings, load_settings
14
+ from loxo_cli.output import render
15
+
16
+ HELP_EPILOG = "Unofficial — not affiliated with Loxo, Inc."
17
+
18
+ app = typer.Typer(
19
+ cls=LoxoGroup,
20
+ help="loxo — command-line interface for the Loxo recruiting API.",
21
+ epilog=HELP_EPILOG,
22
+ no_args_is_help=True,
23
+ )
24
+
25
+
26
+ @dataclass
27
+ class AppState:
28
+ profile: Optional[str]
29
+ api_key: Optional[str]
30
+ slug: Optional[str]
31
+ base_url: Optional[str]
32
+ json_out: bool
33
+ jq: Optional[str]
34
+ verbose: bool
35
+ no_color: bool
36
+ config_path: Optional[Path] = None
37
+ _settings: Optional[LoxoSettings] = field(default=None, repr=False)
38
+
39
+ def settings(self) -> LoxoSettings:
40
+ if self._settings is None:
41
+ self._settings = load_settings(
42
+ profile=self.profile,
43
+ api_key=self.api_key,
44
+ slug=self.slug,
45
+ base_url=self.base_url,
46
+ config_path=self.config_path,
47
+ )
48
+ return self._settings
49
+
50
+ def client(self) -> LoxoClient:
51
+ return build_client(self.settings(), verbose=self.verbose)
52
+
53
+ def console(self) -> Console:
54
+ return Console(no_color=self.no_color)
55
+
56
+ def emit(self, data: Any, *, columns: list[str] | None = None) -> None:
57
+ render(
58
+ data,
59
+ as_json=self.json_out,
60
+ jq=self.jq,
61
+ columns=columns,
62
+ console=self.console(),
63
+ )
64
+
65
+
66
+ def _version_callback(value: bool) -> None:
67
+ if value:
68
+ typer.echo(__version__)
69
+ raise typer.Exit()
70
+
71
+
72
+ @app.callback()
73
+ def main(
74
+ ctx: typer.Context,
75
+ version: bool = typer.Option(
76
+ False, "--version", callback=_version_callback, is_eager=True, help="Show version and exit."
77
+ ),
78
+ profile: Optional[str] = typer.Option(None, "--profile", help="Config profile."),
79
+ api_key: Optional[str] = typer.Option(None, "--api-key", help="Loxo API key."),
80
+ slug: Optional[str] = typer.Option(None, "--slug", help="Agency slug."),
81
+ base_url: Optional[str] = typer.Option(None, "--base-url", help="API base URL."),
82
+ json_out: bool = typer.Option(False, "--json", help="Force JSON output."),
83
+ jq: Optional[str] = typer.Option(None, "--jq", help="Filter output (dotted path)."),
84
+ quiet: bool = typer.Option(False, "--quiet", help="Suppress non-error output."),
85
+ verbose: bool = typer.Option(False, "-v", "--verbose", help="Log requests to stderr."),
86
+ no_color: bool = typer.Option(False, "--no-color", help="Disable color."),
87
+ ) -> None:
88
+ """loxo CLI. Unofficial — not affiliated with Loxo, Inc."""
89
+ ctx.obj = AppState(
90
+ profile=profile,
91
+ api_key=api_key,
92
+ slug=slug,
93
+ base_url=base_url,
94
+ json_out=json_out,
95
+ jq=jq,
96
+ verbose=verbose,
97
+ no_color=no_color,
98
+ )
99
+
100
+
101
+ from loxo_cli.commands import api as _api_cmd # noqa: E402
102
+ from loxo_cli.commands.activities import activities_app # noqa: E402
103
+ from loxo_cli.commands.candidates import candidates_app # noqa: E402
104
+ from loxo_cli.commands.companies import companies_app # noqa: E402
105
+ from loxo_cli.commands.configure import configure_app # noqa: E402
106
+ from loxo_cli.commands.deals import deals_app # noqa: E402
107
+ from loxo_cli.commands.jobs import jobs_app # noqa: E402
108
+ from loxo_cli.commands.people import people_app # noqa: E402
109
+ from loxo_cli.commands.ref import ref_app # noqa: E402
110
+ from loxo_cli.commands.webhooks import webhooks_app # noqa: E402
111
+
112
+ _api_cmd.register(app)
113
+ app.add_typer(configure_app, name="configure")
114
+ app.add_typer(people_app, name="people")
115
+ app.add_typer(jobs_app, name="jobs")
116
+ app.add_typer(companies_app, name="companies")
117
+ app.add_typer(deals_app, name="deals")
118
+ app.add_typer(candidates_app, name="candidates")
119
+ app.add_typer(activities_app, name="activities")
120
+ app.add_typer(webhooks_app, name="webhooks")
121
+ app.add_typer(ref_app, name="ref")
122
+
123
+
124
+ def run() -> None:
125
+ # Exit-code mapping happens in LoxoGroup.invoke (commands/_app.py, set via
126
+ # typer.Typer(cls=LoxoGroup)): Typer does NOT honor a raised ClickException's
127
+ # exit_code, so domain errors become typer.Exit with the mapped code.
128
+ app()
129
+
130
+
131
+ if __name__ == "__main__":
132
+ run()
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from typing import Any, Mapping
5
+
6
+ import httpx
7
+
8
+ from loxo_cli.config import LoxoSettings
9
+ from loxo_cli.errors import LoxoError
10
+
11
+ TIMEOUT = 30.0
12
+
13
+
14
+ def url_for(settings: LoxoSettings, endpoint: str) -> str:
15
+ return f"{settings.base_url}/{settings.slug}/{endpoint.lstrip('/')}"
16
+
17
+
18
+ class LoxoClient:
19
+ def __init__(self, settings: LoxoSettings, *, verbose: bool = False) -> None:
20
+ self._settings = settings
21
+ self._verbose = verbose
22
+ self._http = httpx.Client(
23
+ headers={
24
+ "Authorization": f"Bearer {settings.api_key}",
25
+ "Accept": "application/json",
26
+ },
27
+ follow_redirects=True,
28
+ timeout=TIMEOUT,
29
+ )
30
+
31
+ def __enter__(self) -> "LoxoClient":
32
+ return self
33
+
34
+ def __exit__(self, *exc: object) -> None:
35
+ self.close()
36
+
37
+ def close(self) -> None:
38
+ self._http.close()
39
+
40
+ def request(
41
+ self,
42
+ method: str,
43
+ endpoint: str,
44
+ *,
45
+ params: Mapping[str, Any] | None = None,
46
+ json: Any | None = None,
47
+ ) -> Any:
48
+ target = url_for(self._settings, endpoint)
49
+ if self._verbose:
50
+ # Method + URL only. Never headers (would leak the bearer token).
51
+ print(f"{method.upper()} {target}", file=sys.stderr)
52
+ headers = {"Content-Type": "application/json"} if json is not None else None
53
+ try:
54
+ response = self._http.request(method, target, params=params, json=json, headers=headers)
55
+ response.raise_for_status()
56
+ except httpx.TimeoutException as exc:
57
+ raise LoxoError(
58
+ f"Loxo {method} {endpoint} timed out", status_code=None, is_timeout=True
59
+ ) from exc
60
+ except httpx.HTTPStatusError as exc:
61
+ raise LoxoError(
62
+ f"Loxo {method} {endpoint} returned {exc.response.status_code}: "
63
+ f"{exc.response.text[:500]}",
64
+ status_code=exc.response.status_code,
65
+ ) from exc
66
+ except httpx.HTTPError as exc:
67
+ raise LoxoError(
68
+ f"Loxo {method} {endpoint} request failed: {exc}", status_code=None
69
+ ) from exc
70
+ if not response.content:
71
+ return None
72
+ return response.json()
73
+
74
+ def get(self, endpoint: str, **kw: Any) -> Any:
75
+ return self.request("GET", endpoint, **kw)
76
+
77
+ def post(self, endpoint: str, **kw: Any) -> Any:
78
+ return self.request("POST", endpoint, **kw)
79
+
80
+ def put(self, endpoint: str, **kw: Any) -> Any:
81
+ return self.request("PUT", endpoint, **kw)
82
+
83
+ def delete(self, endpoint: str, **kw: Any) -> Any:
84
+ return self.request("DELETE", endpoint, **kw)
85
+
86
+
87
+ def build_client(settings: LoxoSettings, *, verbose: bool = False) -> LoxoClient:
88
+ return LoxoClient(settings, verbose=verbose)
File without changes
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import typer
6
+ from typer.core import TyperGroup
7
+
8
+ from loxo_cli.errors import ConfigError, LoxoError
9
+
10
+
11
+ class LoxoGroup(TyperGroup):
12
+ """Root command group that maps loxo's domain errors to documented exit codes.
13
+
14
+ Typer's invocation path does NOT honor a raised ``ClickException``'s
15
+ ``exit_code`` (it surfaces as a generic exit 1 with no message). Set this as
16
+ the root app's group class via the supported ``typer.Typer(cls=LoxoGroup)``
17
+ hook: its ``invoke`` wraps the entire command tree, so every command — nested
18
+ sub-app commands and root-level commands alike — gets its ``LoxoError`` /
19
+ ``ConfigError`` converted into ``typer.Exit`` with the mapped code, with a
20
+ clean message on stderr. Command files stay plain ``typer.Typer``.
21
+ """
22
+
23
+ def invoke(self, ctx) -> Any: # ctx is typer's vendored-click Context
24
+ try:
25
+ return super().invoke(ctx)
26
+ except (LoxoError, ConfigError) as exc:
27
+ typer.echo(f"Error: {exc.format_message()}", err=True)
28
+ raise typer.Exit(code=exc.exit_code) from exc