finch-cli 0.1.2__tar.gz → 0.2.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.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.3
2
2
  Name: finch-cli
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: Tailor your resume to any job posting from your terminal.
5
5
  Project-URL: Homepage, https://applyfinch.com
6
6
  Project-URL: Repository, https://github.com/applyEasy/finch-cli
@@ -43,13 +43,20 @@ a terminal client for job hunting. browse fresh internship + new-grad postings,
43
43
 
44
44
  ```
45
45
  pip install finch-cli
46
- export DEEPSEEK_API_KEY=sk-...
46
+ finch login
47
47
  finch ui
48
48
  ```
49
49
 
50
- deepseek keys are cheap (~30x cheaper than the major frontier models). get one at https://platform.deepseek.com/api_keys.
50
+ `finch login` opens a sign-in link in your browser. once you sign in with google or email on applyfinch.com, the cli is paired and tailoring just works -- no api key to manage, the backend pays the llm bill.
51
+
52
+ prefer your own key? skip `finch login` and set one instead:
53
+
54
+ ```
55
+ export DEEPSEEK_API_KEY=sk-...
56
+ finch ui
57
+ ```
51
58
 
52
- other providers work too. any openai-compatible chat-completions endpoint: openai, together, groq, fireworks. pass `--base-url` and the matching `--api-key`, or set `FINCH_BASE_URL` and `OPENAI_API_KEY`. the same `finch_cli/tailor.py` runs against all of them.
59
+ deepseek keys are cheap (~30x cheaper than the major frontier models). get one at https://platform.deepseek.com/api_keys. any openai-compatible chat-completions endpoint also works (openai, together, groq, fireworks): pass `--base-url` and `--api-key`.
53
60
 
54
61
  ## what it does
55
62
 
@@ -10,13 +10,20 @@ a terminal client for job hunting. browse fresh internship + new-grad postings,
10
10
 
11
11
  ```
12
12
  pip install finch-cli
13
- export DEEPSEEK_API_KEY=sk-...
13
+ finch login
14
14
  finch ui
15
15
  ```
16
16
 
17
- deepseek keys are cheap (~30x cheaper than the major frontier models). get one at https://platform.deepseek.com/api_keys.
17
+ `finch login` opens a sign-in link in your browser. once you sign in with google or email on applyfinch.com, the cli is paired and tailoring just works -- no api key to manage, the backend pays the llm bill.
18
+
19
+ prefer your own key? skip `finch login` and set one instead:
20
+
21
+ ```
22
+ export DEEPSEEK_API_KEY=sk-...
23
+ finch ui
24
+ ```
18
25
 
19
- other providers work too. any openai-compatible chat-completions endpoint: openai, together, groq, fireworks. pass `--base-url` and the matching `--api-key`, or set `FINCH_BASE_URL` and `OPENAI_API_KEY`. the same `finch_cli/tailor.py` runs against all of them.
26
+ deepseek keys are cheap (~30x cheaper than the major frontier models). get one at https://platform.deepseek.com/api_keys. any openai-compatible chat-completions endpoint also works (openai, together, groq, fireworks): pass `--base-url` and `--api-key`.
20
27
 
21
28
  ## what it does
22
29
 
@@ -1,3 +1,3 @@
1
1
  """Finch CLI - tailor your resume to any job posting from your terminal."""
2
2
 
3
- __version__ = "0.1.2"
3
+ __version__ = "0.2.0"
@@ -0,0 +1,192 @@
1
+ """finch-cli sign-in flow.
2
+
3
+ Talks to the applyfinch.com backend's device-flow endpoints so users can sign
4
+ in by clicking a link in their terminal instead of pasting an API key. Once
5
+ signed in, the local tailor command POSTs to /api/v1/cli/tailor and the
6
+ backend pays the upstream LLM bill.
7
+
8
+ Token storage: ~/.config/finch-cli/token (chmod 600 on POSIX). One file, one
9
+ token. `finch logout` deletes it.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import platform
16
+ import stat
17
+ import sys
18
+ import time
19
+ import webbrowser
20
+ from pathlib import Path
21
+
22
+ import httpx
23
+
24
+ DEFAULT_FINCH_BASE_URL = "https://applyfinch.com"
25
+ FINCH_BASE_URL = os.environ.get("FINCH_BASE_URL", DEFAULT_FINCH_BASE_URL).rstrip("/")
26
+ START_TIMEOUT = 15
27
+ POLL_TIMEOUT = 10
28
+ POLL_BACKOFF_AFTER_404 = 3
29
+ MAX_POLL_SECONDS = 300
30
+
31
+
32
+ class AuthError(RuntimeError):
33
+ pass
34
+
35
+
36
+ def _config_dir() -> Path:
37
+ base = os.environ.get("XDG_CONFIG_HOME")
38
+ if base:
39
+ return Path(base) / "finch-cli"
40
+ return Path.home() / ".config" / "finch-cli"
41
+
42
+
43
+ def token_path() -> Path:
44
+ return _config_dir() / "token"
45
+
46
+
47
+ def load_token() -> str | None:
48
+ p = token_path()
49
+ if not p.is_file():
50
+ return None
51
+ try:
52
+ token = p.read_text(encoding="utf-8").strip()
53
+ except OSError:
54
+ return None
55
+ return token or None
56
+
57
+
58
+ def save_token(token: str) -> None:
59
+ p = token_path()
60
+ p.parent.mkdir(parents=True, exist_ok=True)
61
+ p.write_text(token + "\n", encoding="utf-8")
62
+ if os.name == "posix":
63
+ try:
64
+ os.chmod(p, stat.S_IRUSR | stat.S_IWUSR)
65
+ except OSError:
66
+ pass
67
+
68
+
69
+ def clear_token() -> bool:
70
+ p = token_path()
71
+ if not p.exists():
72
+ return False
73
+ try:
74
+ p.unlink()
75
+ return True
76
+ except OSError:
77
+ return False
78
+
79
+
80
+ def _device_label() -> str:
81
+ try:
82
+ node = platform.node() or "unknown"
83
+ except Exception:
84
+ node = "unknown"
85
+ return f"finch-cli on {platform.system()} ({node})"[:120]
86
+
87
+
88
+ def start_device_flow() -> dict:
89
+ url = f"{FINCH_BASE_URL}/api/v1/cli/start"
90
+ try:
91
+ with httpx.Client(timeout=START_TIMEOUT) as client:
92
+ r = client.post(url, json={"device_label": _device_label()})
93
+ except httpx.RequestError as e:
94
+ raise AuthError(f"Could not reach {FINCH_BASE_URL}: {e}") from e
95
+ if r.status_code != 200:
96
+ raise AuthError(f"start failed: HTTP {r.status_code} {r.text[:200]}")
97
+ data = r.json()
98
+ for field in ("device_code", "user_code", "verification_url", "expires_in", "interval"):
99
+ if field not in data:
100
+ raise AuthError(f"start response missing {field}")
101
+ return data
102
+
103
+
104
+ def poll_for_token(device_code: str, interval: int, expires_in: int) -> str:
105
+ url = f"{FINCH_BASE_URL}/api/v1/cli/poll"
106
+ poll_interval = max(2, int(interval))
107
+ started = time.monotonic()
108
+ deadline = started + min(expires_in, MAX_POLL_SECONDS)
109
+
110
+ while time.monotonic() < deadline:
111
+ try:
112
+ with httpx.Client(timeout=POLL_TIMEOUT) as client:
113
+ r = client.post(url, json={"device_code": device_code})
114
+ except httpx.RequestError:
115
+ time.sleep(poll_interval)
116
+ continue
117
+
118
+ if r.status_code == 410:
119
+ raise AuthError("Device code expired. Run `finch login` again.")
120
+ if r.status_code != 200:
121
+ time.sleep(poll_interval + POLL_BACKOFF_AFTER_404)
122
+ continue
123
+
124
+ try:
125
+ data = r.json()
126
+ except Exception:
127
+ time.sleep(poll_interval)
128
+ continue
129
+
130
+ status = data.get("status")
131
+ if status == "approved":
132
+ token = data.get("token")
133
+ if not token:
134
+ raise AuthError("Approved but no token returned.")
135
+ return token
136
+ if status == "pending":
137
+ time.sleep(poll_interval)
138
+ continue
139
+ if status == "expired":
140
+ raise AuthError("Device code expired. Run `finch login` again.")
141
+ time.sleep(poll_interval)
142
+
143
+ raise AuthError("Timed out waiting for sign-in. Run `finch login` again.")
144
+
145
+
146
+ def open_browser_safely(url: str) -> bool:
147
+ try:
148
+ if sys.platform.startswith("linux") and not os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY"):
149
+ return False
150
+ return webbrowser.open_new_tab(url)
151
+ except Exception:
152
+ return False
153
+
154
+
155
+ def call_tailor(token: str, base_resume: str, job_text: str, model: str | None = None) -> str:
156
+ url = f"{FINCH_BASE_URL}/api/v1/cli/tailor"
157
+ payload: dict = {"resume": base_resume, "job": job_text}
158
+ if model:
159
+ payload["model"] = model
160
+ headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
161
+ try:
162
+ with httpx.Client(timeout=120) as client:
163
+ r = client.post(url, json=payload, headers=headers)
164
+ except httpx.RequestError as e:
165
+ raise AuthError(f"Could not reach {FINCH_BASE_URL}: {e}") from e
166
+
167
+ if r.status_code == 401:
168
+ raise AuthError("Your finch token is no longer valid. Run `finch logout && finch login`.")
169
+ if r.status_code == 503:
170
+ raise AuthError("The finch backend is misconfigured. Try again later or use --api-key.")
171
+ if r.status_code >= 400:
172
+ try:
173
+ err = r.json().get("error")
174
+ except Exception:
175
+ err = r.text[:200]
176
+ raise AuthError(f"Backend tailor failed (HTTP {r.status_code}): {err}")
177
+
178
+ try:
179
+ data = r.json()
180
+ return data["tailored"]
181
+ except Exception as e:
182
+ raise AuthError(f"Bad backend response: {e}") from e
183
+
184
+
185
+ def revoke_token(token: str) -> bool:
186
+ url = f"{FINCH_BASE_URL}/api/v1/cli/revoke"
187
+ try:
188
+ with httpx.Client(timeout=10) as client:
189
+ r = client.post(url, headers={"Authorization": f"Bearer {token}"})
190
+ return r.status_code == 200
191
+ except httpx.RequestError:
192
+ return False
@@ -9,6 +9,17 @@ import click
9
9
  from rich.console import Console
10
10
 
11
11
  from . import __version__
12
+ from .auth import (
13
+ AuthError,
14
+ FINCH_BASE_URL,
15
+ call_tailor,
16
+ clear_token,
17
+ load_token,
18
+ open_browser_safely,
19
+ poll_for_token,
20
+ revoke_token,
21
+ start_device_flow,
22
+ )
12
23
  from .fetch import FetchError, fetch_job
13
24
  from .output import write_or_print
14
25
  from .tailor import DEFAULT_BASE_URL, DEFAULT_MODEL, TailorError, tailor_resume
@@ -110,28 +121,110 @@ def tailor(
110
121
  sys.exit(1)
111
122
  console.print(f"[dim]Fetched job posting ({len(job_text):,} chars).[/dim]")
112
123
 
113
- if base_url and base_url != DEFAULT_BASE_URL:
114
- console.print(
115
- f"[yellow]warning:[/] sending your API key to {base_url}. "
116
- f"only use --base-url for providers you trust."
117
- )
124
+ finch_token = load_token()
125
+ use_backend = finch_token is not None and not api_key and not base_url
118
126
 
119
- try:
120
- with console.status("Tailoring resume against the job...", spinner="dots"):
121
- tailored = tailor_resume(
122
- base_resume,
123
- job_text,
124
- model=model,
125
- api_key=api_key,
126
- base_url=base_url,
127
+ if use_backend:
128
+ console.print(f"[dim]Signed in as finch user. Tailoring via {FINCH_BASE_URL}.[/dim]")
129
+ try:
130
+ with console.status("Tailoring resume against the job...", spinner="dots"):
131
+ tailored = call_tailor(finch_token, base_resume, job_text, model=None)
132
+ except AuthError as e:
133
+ console.print(f"[red]Tailoring failed:[/red] {e}")
134
+ sys.exit(1)
135
+ else:
136
+ if base_url and base_url != DEFAULT_BASE_URL:
137
+ console.print(
138
+ f"[yellow]warning:[/] sending your API key to {base_url}. "
139
+ f"only use --base-url for providers you trust."
127
140
  )
128
- except TailorError as e:
129
- console.print(f"[red]Tailoring failed:[/red] {e}")
130
- sys.exit(1)
141
+ try:
142
+ with console.status("Tailoring resume against the job...", spinner="dots"):
143
+ tailored = tailor_resume(
144
+ base_resume,
145
+ job_text,
146
+ model=model,
147
+ api_key=api_key,
148
+ base_url=base_url,
149
+ )
150
+ except TailorError as e:
151
+ console.print(f"[red]Tailoring failed:[/red] {e}")
152
+ sys.exit(1)
131
153
 
132
154
  write_or_print(tailored, out_path)
133
155
 
134
156
 
157
+ @main.command()
158
+ def login() -> None:
159
+ """Sign in to finch through your browser. No API key needed."""
160
+ if load_token():
161
+ console.print("[yellow]You're already signed in.[/] Run [bold]finch logout[/] first to sign in as someone else.")
162
+ return
163
+
164
+ try:
165
+ with console.status("Starting sign-in...", spinner="dots"):
166
+ data = start_device_flow()
167
+ except AuthError as e:
168
+ console.print(f"[red]Could not start sign-in:[/red] {e}")
169
+ sys.exit(1)
170
+
171
+ url = data["verification_url"]
172
+ user_code = data["user_code"]
173
+
174
+ console.print()
175
+ console.print(f"[bold]Open this URL in your browser to sign in:[/bold]")
176
+ console.print(f" [cyan]{url}[/cyan]")
177
+ console.print()
178
+ console.print(f"If your browser does not open automatically, the code is [bold]{user_code}[/bold].")
179
+ console.print()
180
+
181
+ opened = open_browser_safely(url)
182
+ if opened:
183
+ console.print("[dim]Opened your browser. Sign in, then come back here.[/dim]")
184
+ else:
185
+ console.print("[dim]Could not open a browser automatically. Open the URL above on any device.[/dim]")
186
+
187
+ try:
188
+ with console.status("Waiting for sign-in...", spinner="dots"):
189
+ token = poll_for_token(data["device_code"], data["interval"], data["expires_in"])
190
+ except AuthError as e:
191
+ console.print(f"[red]Sign-in failed:[/red] {e}")
192
+ sys.exit(1)
193
+
194
+ from .auth import save_token
195
+ save_token(token)
196
+ console.print()
197
+ console.print("[green]Signed in.[/green] You can now run [bold]finch tailor[/] without an API key.")
198
+
199
+
200
+ @main.command()
201
+ def logout() -> None:
202
+ """Sign out of finch and delete the local token."""
203
+ token = load_token()
204
+ if not token:
205
+ console.print("You're not signed in.")
206
+ return
207
+ revoke_token(token)
208
+ cleared = clear_token()
209
+ if cleared:
210
+ console.print("[green]Signed out.[/green]")
211
+ else:
212
+ console.print("[yellow]Could not delete the local token file.[/]")
213
+
214
+
215
+ @main.command()
216
+ def whoami() -> None:
217
+ """Show whether you're signed in to finch."""
218
+ token = load_token()
219
+ if not token:
220
+ console.print("Not signed in. Run [bold]finch login[/].")
221
+ return
222
+ from .auth import token_path
223
+ console.print("[green]Signed in to finch.[/green]")
224
+ console.print(f" token file: {token_path()}")
225
+ console.print(f" backend: {FINCH_BASE_URL}")
226
+
227
+
135
228
  @main.command()
136
229
  @click.option(
137
230
  "--demo",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "finch-cli"
3
- version = "0.1.2"
3
+ version = "0.2.0"
4
4
  description = "Tailor your resume to any job posting from your terminal."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes