loxo-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
loxo_cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
loxo_cli/__main__.py ADDED
@@ -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()
loxo_cli/client.py ADDED
@@ -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
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Any, TextIO
7
+
8
+ import typer
9
+
10
+
11
+ def load_data(raw: str | None, *, stdin: TextIO | None = None) -> dict:
12
+ if raw is None:
13
+ return {}
14
+ try:
15
+ if raw == "-":
16
+ source = stdin or sys.stdin
17
+ return json.load(source)
18
+ if raw.startswith("@"):
19
+ return json.loads(Path(raw[1:]).read_text())
20
+ return json.loads(raw)
21
+ except (json.JSONDecodeError, OSError) as exc:
22
+ raise typer.BadParameter(f"Invalid --data JSON: {exc}") from exc
23
+
24
+
25
+ def parse_fields(fields: list[str]) -> dict[str, Any]:
26
+ result: dict[str, Any] = {}
27
+ for item in fields:
28
+ if "=" not in item:
29
+ raise typer.BadParameter(f"--field must be key=value, got {item!r}")
30
+ key, value = item.split("=", 1)
31
+ force_list = key.endswith("[]")
32
+ if force_list:
33
+ key = key[:-2]
34
+ if key in result:
35
+ existing = result[key]
36
+ result[key] = existing + [value] if isinstance(existing, list) else [existing, value]
37
+ elif force_list:
38
+ result[key] = [value]
39
+ else:
40
+ result[key] = value
41
+ return result
42
+
43
+
44
+ def build_payload(resource_key: str, typed: dict, data: dict, fields: dict) -> dict:
45
+ merged: dict[str, Any] = dict(data)
46
+ merged.update({k: v for k, v in typed.items() if v is not None})
47
+ merged.update(fields)
48
+ return {resource_key: merged}
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ import typer
6
+
7
+ from loxo_cli.commands._helpers import build_payload, load_data, parse_fields
8
+ from loxo_cli.pagination import paginate
9
+
10
+ activities_app = typer.Typer(
11
+ help="Manage person events/activities. Unofficial — not affiliated with Loxo, Inc."
12
+ )
13
+
14
+
15
+ @activities_app.command("list")
16
+ def list_activities(
17
+ ctx: typer.Context,
18
+ person_id: Optional[int] = typer.Option(None, "--person-id"),
19
+ job_id: Optional[int] = typer.Option(None, "--job-id"),
20
+ all_pages: bool = typer.Option(False, "--all"),
21
+ per_page: int = typer.Option(50, "--per-page"),
22
+ ) -> None:
23
+ state = ctx.obj
24
+ params: dict = {}
25
+ if person_id is not None:
26
+ params["person_id"] = person_id
27
+ if job_id is not None:
28
+ params["job_id"] = job_id
29
+ client = state.client()
30
+ if all_pages:
31
+ rows = list(
32
+ paginate(
33
+ client,
34
+ "person_events",
35
+ scheme="scroll_id",
36
+ items_key="person_events",
37
+ params=params,
38
+ per_page=per_page,
39
+ )
40
+ )
41
+ else:
42
+ params["per_page"] = per_page
43
+ data = client.get("person_events", params=params)
44
+ rows = data.get("person_events", []) if isinstance(data, dict) else data
45
+ state.emit(rows, columns=["id", "activity_type_id", "person_id", "notes"])
46
+
47
+
48
+ @activities_app.command("add")
49
+ def add_activity(
50
+ ctx: typer.Context,
51
+ activity_type_id: int = typer.Option(..., "--activity-type-id"),
52
+ person_id: int = typer.Option(..., "--person-id"),
53
+ job_id: Optional[int] = typer.Option(None, "--job-id"),
54
+ company_id: Optional[int] = typer.Option(None, "--company-id"),
55
+ notes: Optional[str] = typer.Option(None, "--notes"),
56
+ field: list[str] = typer.Option([], "--field"),
57
+ data: Optional[str] = typer.Option(None, "--data", "-d"),
58
+ ) -> None:
59
+ state = ctx.obj
60
+ raw = load_data(data)
61
+ inner = raw.get("person_event", raw)
62
+ typed = {
63
+ "activity_type_id": activity_type_id,
64
+ "person_id": person_id,
65
+ "job_id": job_id,
66
+ "company_id": company_id,
67
+ "notes": notes,
68
+ }
69
+ payload = build_payload("person_event", typed, inner, parse_fields(field))
70
+ result = state.client().post("person_events", json=payload)
71
+ state.emit(result)
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ import typer
6
+
7
+ from loxo_cli.commands._helpers import load_data, parse_fields
8
+ from loxo_cli.pagination import detect_scheme, extract_items, paginate
9
+
10
+
11
+ def register(app: typer.Typer) -> None:
12
+ app.command(
13
+ "api",
14
+ help="Call any Loxo endpoint directly. " "Unofficial — not affiliated with Loxo, Inc.",
15
+ )(api_command)
16
+
17
+
18
+ def api_command(
19
+ ctx: typer.Context,
20
+ method: str = typer.Argument(..., help="HTTP method: GET/POST/PUT/DELETE."),
21
+ path: str = typer.Argument(..., help="Endpoint path, e.g. people or jobs/123."),
22
+ param: list[str] = typer.Option(
23
+ [], "--param", "-p", help="Query param key=value (repeatable)."
24
+ ),
25
+ data: Optional[str] = typer.Option(
26
+ None, "--data", "-d", help="JSON body: inline, @file, or - for stdin."
27
+ ),
28
+ raw: bool = typer.Option(False, "--raw", help="No-op for the generic command (always raw)."),
29
+ all_pages: bool = typer.Option(False, "--all", help="Auto-paginate all pages."),
30
+ paginate_scheme: Optional[str] = typer.Option(
31
+ None, "--paginate", help="Force scheme: scroll_id|page|after_id."
32
+ ),
33
+ ) -> None:
34
+ state = ctx.obj
35
+ params = parse_fields(param)
36
+ body = load_data(data) or None
37
+ client = state.client()
38
+
39
+ if all_pages:
40
+ if method.upper() != "GET":
41
+ raise typer.BadParameter("--all only supports GET (pagination is GET-only).")
42
+ scheme = paginate_scheme
43
+ if scheme is None:
44
+ first = client.get(path, params=params)
45
+ scheme = detect_scheme(first)
46
+ # Collect first page items and build continuation params so we
47
+ # don't re-fetch the first page inside paginate().
48
+ first_items = extract_items(first, None)
49
+ cont_params = dict(params or {})
50
+ if scheme == "scroll_id" and isinstance(first, dict):
51
+ next_sid = first.get("scroll_id")
52
+ if next_sid:
53
+ cont_params["scroll_id"] = next_sid
54
+ items = first_items + list(
55
+ paginate(client, path, scheme=scheme, params=cont_params)
56
+ )
57
+ else:
58
+ items = first_items
59
+ else:
60
+ # For page / after_id schemes, fall back to re-paginating from
61
+ # the start; the first-page data is small and the scheme is rare.
62
+ items = list(paginate(client, path, scheme=scheme, params=params))
63
+ else:
64
+ items = list(paginate(client, path, scheme=scheme, params=params))
65
+ state.emit(items)
66
+ return
67
+
68
+ result = client.request(method.upper(), path, params=params, json=body)
69
+ state.emit(result)
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ import typer
6
+
7
+ from loxo_cli.commands._helpers import load_data, parse_fields
8
+ from loxo_cli.models.candidate import Candidate
9
+ from loxo_cli.pagination import paginate
10
+
11
+ candidates_app = typer.Typer(
12
+ help="Manage job candidates. Unofficial — not affiliated with Loxo, Inc."
13
+ )
14
+
15
+ LIST_COLUMNS = ["id", "person_id", "job_id"]
16
+
17
+
18
+ @candidates_app.command("list")
19
+ def list_candidates(
20
+ ctx: typer.Context,
21
+ job: int = typer.Option(..., "--job", "-j"),
22
+ all_pages: bool = typer.Option(False, "--all"),
23
+ per_page: int = typer.Option(50, "--per-page"),
24
+ ) -> None:
25
+ state = ctx.obj
26
+ endpoint = f"jobs/{job}/candidates"
27
+ client = state.client()
28
+ if all_pages:
29
+ rows = [
30
+ Candidate.model_validate(i)
31
+ for i in paginate(
32
+ client, endpoint, scheme="scroll_id", items_key="candidates", per_page=per_page
33
+ )
34
+ ]
35
+ else:
36
+ data = client.get(endpoint, params={"per_page": per_page})
37
+ rows = [Candidate.model_validate(i) for i in data.get("candidates", [])]
38
+ state.emit(rows, columns=LIST_COLUMNS)
39
+
40
+
41
+ @candidates_app.command("get")
42
+ def get_candidate(
43
+ ctx: typer.Context,
44
+ candidate_id: int = typer.Argument(...),
45
+ job: int = typer.Option(..., "--job", "-j"),
46
+ ) -> None:
47
+ state = ctx.obj
48
+ data = state.client().get(f"jobs/{job}/candidates/{candidate_id}")
49
+ state.emit(Candidate.model_validate(data))
50
+
51
+
52
+ @candidates_app.command(
53
+ "add",
54
+ help="Add a person to a job as a candidate. If Loxo rejects the body shape, "
55
+ "use `loxo api POST jobs/<id>/candidates --data @body.json`.",
56
+ )
57
+ def add_candidate(
58
+ ctx: typer.Context,
59
+ job: int = typer.Option(..., "--job", "-j"),
60
+ person: int = typer.Option(..., "--person"),
61
+ field: list[str] = typer.Option([], "--field"),
62
+ data: Optional[str] = typer.Option(None, "--data", "-d"),
63
+ ) -> None:
64
+ state = ctx.obj
65
+ body = load_data(data)
66
+ body.setdefault("person_id", person)
67
+ body.update(parse_fields(field))
68
+ result = state.client().post(f"jobs/{job}/candidates", json=body)
69
+ state.emit(Candidate.model_validate(result) if isinstance(result, dict) else result)
70
+
71
+
72
+ @candidates_app.command("update")
73
+ def update_candidate(
74
+ ctx: typer.Context,
75
+ candidate_id: int = typer.Argument(...),
76
+ job: int = typer.Option(..., "--job", "-j"),
77
+ highlights: Optional[str] = typer.Option(None, "--highlights"),
78
+ field: list[str] = typer.Option([], "--field"),
79
+ data: Optional[str] = typer.Option(None, "--data", "-d"),
80
+ ) -> None:
81
+ state = ctx.obj
82
+ body = load_data(data)
83
+ if highlights is not None:
84
+ body["highlights"] = highlights
85
+ body.update(parse_fields(field))
86
+ result = state.client().put(f"jobs/{job}/candidates/{candidate_id}", json=body)
87
+ state.emit(result)
@@ -0,0 +1,100 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ import typer
6
+
7
+ from loxo_cli.commands._helpers import build_payload, load_data, parse_fields
8
+ from loxo_cli.models.base import unwrap_envelope
9
+ from loxo_cli.models.company import Company
10
+ from loxo_cli.pagination import paginate
11
+
12
+ companies_app = typer.Typer(help="Manage companies. Unofficial — not affiliated with Loxo, Inc.")
13
+
14
+ LIST_COLUMNS = ["id", "name", "url"]
15
+
16
+
17
+ def _list(state, query, all_pages, per_page):
18
+ params = {"query": query} if query else {}
19
+ client = state.client()
20
+ if all_pages:
21
+ rows = [
22
+ Company.model_validate(i)
23
+ for i in paginate(
24
+ client,
25
+ "companies",
26
+ scheme="scroll_id",
27
+ items_key="companies",
28
+ params=params,
29
+ per_page=per_page,
30
+ )
31
+ ]
32
+ else:
33
+ params["per_page"] = per_page
34
+ data = client.get("companies", params=params)
35
+ rows = [Company.model_validate(i) for i in data.get("companies", [])]
36
+ state.emit(rows, columns=LIST_COLUMNS)
37
+
38
+
39
+ @companies_app.command("list")
40
+ def list_companies(
41
+ ctx: typer.Context,
42
+ query: Optional[str] = typer.Option(None, "--query", "-q"),
43
+ all_pages: bool = typer.Option(False, "--all"),
44
+ per_page: int = typer.Option(50, "--per-page"),
45
+ ) -> None:
46
+ _list(ctx.obj, query, all_pages, per_page)
47
+
48
+
49
+ @companies_app.command(
50
+ "search",
51
+ help="Search companies by query. NOTE: the company 'url' field is not a "
52
+ "searchable Lucene field; pass a bare domain as a full-text query.",
53
+ )
54
+ def search_companies(
55
+ ctx: typer.Context,
56
+ query: str = typer.Option(..., "--query", "-q"),
57
+ all_pages: bool = typer.Option(False, "--all"),
58
+ per_page: int = typer.Option(50, "--per-page"),
59
+ ) -> None:
60
+ _list(ctx.obj, query, all_pages, per_page)
61
+
62
+
63
+ @companies_app.command("get")
64
+ def get_company(ctx: typer.Context, company_id: int = typer.Argument(...)) -> None:
65
+ state = ctx.obj
66
+ data = state.client().get(f"companies/{company_id}")
67
+ state.emit(Company.model_validate(unwrap_envelope(data, "company")))
68
+
69
+
70
+ @companies_app.command("create")
71
+ def create_company(
72
+ ctx: typer.Context,
73
+ name: Optional[str] = typer.Option(None, "--name"),
74
+ url: Optional[str] = typer.Option(None, "--url"),
75
+ field: list[str] = typer.Option([], "--field"),
76
+ data: Optional[str] = typer.Option(None, "--data", "-d"),
77
+ ) -> None:
78
+ state = ctx.obj
79
+ raw = load_data(data)
80
+ inner = raw.get("company", raw)
81
+ payload = build_payload("company", {"name": name, "url": url}, inner, parse_fields(field))
82
+ result = state.client().post("companies", json=payload)
83
+ state.emit(Company.model_validate(unwrap_envelope(result, "company")))
84
+
85
+
86
+ @companies_app.command("update")
87
+ def update_company(
88
+ ctx: typer.Context,
89
+ company_id: int = typer.Argument(...),
90
+ name: Optional[str] = typer.Option(None, "--name"),
91
+ url: Optional[str] = typer.Option(None, "--url"),
92
+ field: list[str] = typer.Option([], "--field"),
93
+ data: Optional[str] = typer.Option(None, "--data", "-d"),
94
+ ) -> None:
95
+ state = ctx.obj
96
+ raw = load_data(data)
97
+ inner = raw.get("company", raw)
98
+ payload = build_payload("company", {"name": name, "url": url}, inner, parse_fields(field))
99
+ result = state.client().put(f"companies/{company_id}", json=payload)
100
+ state.emit(Company.model_validate(unwrap_envelope(result, "company")))
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ import typer
7
+
8
+ from loxo_cli.config import DEFAULT_BASE_URL, config_file_path, list_profiles, write_profile
9
+
10
+ configure_app = typer.Typer(
11
+ help="Set up credential profiles. Unofficial — not affiliated with Loxo, Inc.",
12
+ invoke_without_command=True,
13
+ )
14
+
15
+
16
+ @configure_app.callback(invoke_without_command=True)
17
+ def configure(
18
+ ctx: typer.Context,
19
+ name: Optional[str] = typer.Option(None, "--name"),
20
+ slug: Optional[str] = typer.Option(None, "--slug"),
21
+ base_url: Optional[str] = typer.Option(None, "--base-url"),
22
+ api_key: Optional[str] = typer.Option(None, "--api-key"),
23
+ config_path: Optional[Path] = typer.Option(None, "--config-path"),
24
+ ) -> None:
25
+ if ctx.invoked_subcommand is not None:
26
+ return
27
+ name = name or typer.prompt("Profile name", default="default")
28
+ slug = slug or typer.prompt("Agency slug")
29
+ base_url = base_url or DEFAULT_BASE_URL
30
+ api_key = api_key or typer.prompt("API key", hide_input=True)
31
+ write_profile(
32
+ name,
33
+ api_key=api_key,
34
+ slug=slug,
35
+ base_url=base_url,
36
+ make_default=False,
37
+ config_path=config_path,
38
+ )
39
+ target = config_path or config_file_path()
40
+ typer.echo(f"Saved profile '{name}' to {target}")
41
+
42
+
43
+ @configure_app.command("list")
44
+ def list_cmd(
45
+ config_path: Optional[Path] = typer.Option(None, "--config-path"),
46
+ ) -> None:
47
+ profiles = list_profiles(config_path=config_path)
48
+ if not profiles:
49
+ typer.echo("No profiles configured. Run `loxo configure`.")
50
+ return
51
+ for pname, info in profiles.items():
52
+ default = " (default)" if info["default"] else ""
53
+ key = "set" if info["has_key"] else "missing"
54
+ typer.echo(
55
+ f"{pname}{default}: slug={info['slug']} " f"base_url={info['base_url']} api_key={key}"
56
+ )