cf-tool-cli 1.0.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.
@@ -0,0 +1,399 @@
1
+ """Commands for problems and contests: get, race, new, listen, pick,
2
+ problems, contests, open."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import re
7
+ import time
8
+ from pathlib import Path
9
+
10
+ import click
11
+ from rich.table import Table
12
+ from rich.text import Text
13
+
14
+ from cf_tool import languages, listen as listen_module, ui, views, workspace
15
+ from cf_tool.cli.common import (
16
+ cf_command,
17
+ client,
18
+ open_url,
19
+ settings,
20
+ short,
21
+ starter_language,
22
+ starter_values,
23
+ target_of,
24
+ write_starter,
25
+ )
26
+ from cf_tool.cli.completion import complete_contests, complete_languages, complete_problems
27
+ from cf_tool.errors import CFError
28
+ from cf_tool.workspace import Target
29
+
30
+ # ┌──────────────────────────────────────────────────────────┐
31
+ # │ cf get │
32
+ # └──────────────────────────────────────────────────────────┘
33
+
34
+
35
+ def set_up(target: Target, key: str, tries: int = 1) -> Path:
36
+ """Fetch target's problems (tries times, 2 s apart, while there are none
37
+ yet) and give each its folder: tests, info and starter code; returns the
38
+ contest's folder."""
39
+ for attempt in range(tries):
40
+ try:
41
+ found = client().fetch(target)
42
+ except CFError:
43
+ if attempt == tries - 1:
44
+ raise
45
+ found = []
46
+ if found:
47
+ break
48
+ if attempt < tries - 1:
49
+ time.sleep(2) # NOTE: right at the start, the problems may not show yet
50
+ if not found:
51
+ wait = f"cf race {target.contest} waits for its start"
52
+ raise CFError(f"{target.label} has no problems to see yet: {wait}")
53
+ root = workspace.get_root(Path.cwd(), settings().root_folder())
54
+ folders = []
55
+ grid = Table.grid(padding=(0, 2))
56
+ for column in ("folder", "name", "tests", "code", "notes"):
57
+ grid.add_column(no_wrap=column != "notes")
58
+ for problem in found:
59
+ problem_target = target.with_problem(problem.index)
60
+ folder = workspace.problem_folder(root, problem_target)
61
+ if problem.samples:
62
+ workspace.save_samples(folder, problem.samples)
63
+ workspace.save_info(folder, problem.info)
64
+ if problem.statement:
65
+ workspace.save_statement(folder, problem.statement)
66
+ code = write_starter(folder, problem_target, problem.info, key)
67
+ folders.append(folder)
68
+ count = len(problem.samples)
69
+ notes = []
70
+ if problem.error:
71
+ notes.append(f"no samples: {problem.error}")
72
+ if not problem.standard_io:
73
+ notes.append("reads and writes files")
74
+ if problem.info.interactive:
75
+ notes.append("interactive: cf test needs an interactor")
76
+ grid.add_row(
77
+ Text(short(folder), style=f"bold {ui.ACCENT}"),
78
+ Text(problem.info.name or problem.index),
79
+ Text(f"{count} test{'s' if count != 1 else ''}", style=ui.MUTED),
80
+ Text(code.name if code is not None else "", style=ui.MUTED),
81
+ Text("; ".join(notes), style=ui.WARNING),
82
+ )
83
+ ui.console.print(grid)
84
+ then = "cf test" if len(folders) == 1 else "cf test and cf submit there"
85
+ ui.line(("→ ", "cyan bold"), (f"cd {short(folders[0])}", "bold"), (f", then {then}", "dim"))
86
+ return folders[0].parent
87
+
88
+
89
+ @click.command()
90
+ @click.argument("specs", nargs=-1, metavar="CONTEST|PROBLEM|URL", shell_complete=complete_contests)
91
+ @click.option(
92
+ "-l",
93
+ "--lang",
94
+ "language",
95
+ metavar="EXT",
96
+ shell_complete=complete_languages,
97
+ help="Starter code in this language.",
98
+ )
99
+ @cf_command
100
+ def get(specs: tuple[str, ...], language: str | None) -> None:
101
+ """Set up a contest or a problem: a folder for each problem, with its
102
+ samples as tests and your starter code.
103
+
104
+ \b
105
+ cf get 2050 every problem of contest 2050: 2050/a, 2050/b, ...
106
+ cf get 2050b one problem
107
+ cf get URL a contest's or a problem's page (gym, groups too)
108
+ """
109
+ target = target_of(specs)
110
+ if not target.contest:
111
+ raise CFError("Which contest or problem? Like cf get 2050, or cf get 2050b")
112
+ set_up(target, starter_language(language))
113
+
114
+
115
+ @click.command()
116
+ @click.argument("specs", nargs=-1, metavar="CONTEST", shell_complete=complete_contests)
117
+ @click.option(
118
+ "-l",
119
+ "--lang",
120
+ "language",
121
+ metavar="EXT",
122
+ shell_complete=complete_languages,
123
+ help="Starter code in this language.",
124
+ )
125
+ @cf_command
126
+ def race(specs: tuple[str, ...], language: str | None) -> None:
127
+ """Count down to a contest's start, then open its problems and get them."""
128
+ target = target_of(specs)
129
+ if not target.contest:
130
+ raise CFError("Which contest? Like cf race 2050")
131
+ key = starter_language(language) # NOTE: asked now, not when the contest starts
132
+ contest = Target(target.kind, target.contest, "", target.group)
133
+ ui.line(("Waiting for ", "cyan"), (contest.label, "bold"))
134
+ client().race(contest)
135
+ open_url(contest.problems_url(settings().host))
136
+ set_up(contest, key, tries=5)
137
+
138
+
139
+ # ┌──────────────────────────────────────────────────────────┐
140
+ # │ cf new │
141
+ # └──────────────────────────────────────────────────────────┘
142
+
143
+
144
+ @click.command()
145
+ @click.argument("name", required=False)
146
+ @click.option(
147
+ "-l",
148
+ "--lang",
149
+ "language",
150
+ metavar="EXT",
151
+ shell_complete=complete_languages,
152
+ help="The language, when NAME has none.",
153
+ )
154
+ @cf_command
155
+ def new(name: str | None, language: str | None) -> None:
156
+ """Write a code file from your starter code into this folder.
157
+
158
+ \b
159
+ cf new <problem>.<ext>, like a.cpp in 2050/a
160
+ cf new brute brute.cpp, from templates/brute.cpp when you have one
161
+ cf new gen.py gen.py: the extension picks the language
162
+ """
163
+ cwd = Path.cwd()
164
+ folder = workspace.find_problem_folder(cwd)
165
+ info = workspace.load_info(folder) if folder else None
166
+ target = workspace.here(cwd)
167
+ key = languages.language_key(name, settings()) if name and "." in name else None
168
+ key = key or starter_language(language)
169
+ stem = (
170
+ Path(name).stem
171
+ if name
172
+ else (target.problem.lower() if target and target.problem else "main")
173
+ )
174
+ path = cwd / f"{stem}.{key}"
175
+ if path.exists():
176
+ raise CFError(f"{path.name} exists already")
177
+ text, origin = languages.starter(key, settings(), stem)
178
+ path.write_text(
179
+ languages.render(text, starter_values(target, info)), encoding="utf-8", newline=""
180
+ )
181
+ source = "the built-in starter" if origin == "built-in" else short(origin)
182
+ ui.line(("Wrote ", "green"), (path.name, "bold green"), (f" from {source}", "dim"))
183
+
184
+
185
+ # ┌──────────────────────────────────────────────────────────┐
186
+ # │ cf listen │
187
+ # └──────────────────────────────────────────────────────────┘
188
+
189
+
190
+ @click.command()
191
+ @click.option(
192
+ "--port",
193
+ default=listen_module.DEFAULT_PORT,
194
+ show_default=True,
195
+ help="The port (one the extension sends to, or one added in its settings).",
196
+ )
197
+ @click.option("--keep", is_flag=True, help="Keep listening after a contest or a problem arrived.")
198
+ @click.option(
199
+ "-l",
200
+ "--lang",
201
+ "language",
202
+ metavar="EXT",
203
+ shell_complete=complete_languages,
204
+ help="Starter code in this language.",
205
+ )
206
+ @cf_command
207
+ def listen(port: int, keep: bool, language: str | None) -> None:
208
+ """Receive problems from the Competitive Companion browser extension.
209
+
210
+ Run it, then click the extension's button on a problem or contest page:
211
+ the problems land where cf get would put them, and cf fetches no page.
212
+ """
213
+ key = starter_language(language)
214
+ root = workspace.get_root(Path.cwd(), settings().root_folder())
215
+ listen_module.listen(
216
+ root,
217
+ lambda folder, target, info: write_starter(folder, target, info, key),
218
+ port=port,
219
+ keep=keep,
220
+ )
221
+
222
+
223
+ # ┌──────────────────────────────────────────────────────────┐
224
+ # │ cf pick │
225
+ # └──────────────────────────────────────────────────────────┘
226
+
227
+ # for Tab completion: the problemset's tags
228
+ TAGS = (
229
+ "*special", "2-sat", "binary search", "bitmasks", "brute force", "chinese remainder theorem",
230
+ "combinatorics", "communication", "constructive algorithms", "data structures",
231
+ "dfs and similar", "divide and conquer", "dp", "dsu", "expression parsing", "fft", "flows",
232
+ "games", "geometry", "graph matchings", "graphs", "greedy", "hashing", "implementation",
233
+ "interactive", "math", "matrices", "meet-in-the-middle", "number theory", "probabilities",
234
+ "schedules", "shortest paths", "sortings", "string suffix structures", "strings",
235
+ "ternary search", "trees", "two pointers",
236
+ ) # fmt: skip
237
+
238
+
239
+ def _rating_range(ctx, param, value: str | None) -> tuple[int, int] | None:
240
+ """--rating 1600 or 1400-1800 as (low, high)."""
241
+ if value is None:
242
+ return None
243
+ match = re.fullmatch(r"(\d+)(?:-(\d+))?", value.strip())
244
+ if not match:
245
+ raise click.BadParameter("a rating like 1600, or a range like 1400-1800")
246
+ low, high = int(match.group(1)), int(match.group(2) or match.group(1))
247
+ return min(low, high), max(low, high)
248
+
249
+
250
+ def _tags(ctx, param, incomplete: str):
251
+ from cf_tool.cli.completion import words
252
+
253
+ return words(incomplete, [*TAGS, *(f"!{tag}" for tag in TAGS)])
254
+
255
+
256
+ @click.command()
257
+ @click.option(
258
+ "-r", "--rating", callback=_rating_range, metavar="R|LOW-HIGH", help="e.g. 1600 or 1400-1800."
259
+ )
260
+ @click.option(
261
+ "-t",
262
+ "--tag",
263
+ "tags",
264
+ multiple=True,
265
+ shell_complete=_tags,
266
+ help='A tag it has (repeatable); "!tag" for one it hasn\'t.',
267
+ )
268
+ @click.option("--handle", default="", help="Whose solved problems to skip (default: yours).")
269
+ @click.option("--get", "get_it", is_flag=True, help="Set it up, as cf get does.")
270
+ @click.option("--open", "open_it", is_flag=True, help="Open it in the browser.")
271
+ @click.option(
272
+ "-l",
273
+ "--lang",
274
+ "language",
275
+ metavar="EXT",
276
+ shell_complete=complete_languages,
277
+ help="Starter code in this language.",
278
+ )
279
+ @cf_command
280
+ def pick(
281
+ rating: tuple[int, int] | None,
282
+ tags: tuple[str, ...],
283
+ handle: str,
284
+ get_it: bool,
285
+ open_it: bool,
286
+ language: str | None,
287
+ ) -> None:
288
+ """A random problem you haven't solved, by rating and tags, to practice on."""
289
+ from cf_tool.codeforces.client import pick_problem
290
+
291
+ c = client()
292
+ handle = handle or c.handle
293
+ with ui.status("Reading the problemset..."):
294
+ problems = c.problemset()
295
+ solved = c.solved(handle) if handle else set()
296
+ if not handle:
297
+ ui.yellow("Not logged in: solved problems aren't skipped (or give --handle).")
298
+ low, high = rating or (None, None)
299
+ problem, pool = pick_problem(problems, solved, low=low, high=high, tags=tags)
300
+ if problem is None:
301
+ raise CFError("No problem you haven't solved matches that")
302
+ target = workspace.parse_target([problem.id])
303
+ ui.line(
304
+ (problem.id, "bold green"),
305
+ (f" {problem.name}", "bold"),
306
+ (f" rating {problem.rating}" if problem.rating else " unrated", "cyan"),
307
+ )
308
+ ui.line((", ".join(problem.tags) or "no tags", "dim"))
309
+ url = target.problem_url(c.host)
310
+ ui.plain(f"{url} (one of {pool} to pick from)")
311
+ if open_it:
312
+ open_url(url)
313
+ if get_it:
314
+ set_up(target, starter_language(language))
315
+
316
+
317
+ # ┌──────────────────────────────────────────────────────────┐
318
+ # │ cf problems, contests, open │
319
+ # └──────────────────────────────────────────────────────────┘
320
+
321
+
322
+ @click.command()
323
+ @click.argument("specs", nargs=-1, metavar="[CONTEST]", shell_complete=complete_problems)
324
+ @cf_command
325
+ def problems(specs: tuple[str, ...]) -> None:
326
+ """A contest's problems, with how many solved each (yours marked)."""
327
+ target = target_of(specs)
328
+ ui.console.print(views.problems_table(client().problem_rows(target)))
329
+
330
+
331
+ @click.command()
332
+ @click.option(
333
+ "--ics",
334
+ "ics_path",
335
+ type=click.Path(dir_okay=False),
336
+ help="Also save them as a calendar file (.ics) to import.",
337
+ )
338
+ @cf_command
339
+ def contests(ics_path: str | None) -> None:
340
+ """Upcoming contests, soonest first."""
341
+ c = client()
342
+ upcoming = c.contests()
343
+ if not upcoming:
344
+ ui.plain("No contest is scheduled right now.")
345
+ return
346
+ ui.console.print(views.contests_table(upcoming, time.time()))
347
+ if ics_path:
348
+ Path(ics_path).write_text(
349
+ views.to_ics(upcoming, c.host, time.time()), encoding="utf-8", newline=""
350
+ )
351
+ ui.green(f"Saved them to {ics_path}: import it into your calendar")
352
+
353
+
354
+ def _open(specs: tuple[str, ...], standings: bool, last: bool) -> None:
355
+ host = settings().host
356
+ urls = [spec for spec in specs if spec.startswith(("http://", "https://"))]
357
+ if urls and not (standings or last):
358
+ open_url(urls[0])
359
+ return
360
+ if last:
361
+ saved = client().session.last_submission
362
+ if not saved or not saved.get("submission"):
363
+ raise CFError("No submission yet: cf submit remembers the last one")
364
+ target = Target(
365
+ saved["kind"] or "contest", saved["contest"], saved["problem"], saved["group"]
366
+ )
367
+ open_url(target.submission_url(host, saved["submission"]))
368
+ return
369
+ target = target_of(specs)
370
+ open_url(target.standings_url(host) if standings else target.page_url(host))
371
+
372
+
373
+ @click.command("open")
374
+ @click.argument("specs", nargs=-1, metavar="[PROBLEM|CONTEST]", shell_complete=complete_problems)
375
+ @click.option("-s", "--standings", is_flag=True, help="The contest's standings.")
376
+ @click.option("--last", is_flag=True, help="Your last submission.")
377
+ @cf_command
378
+ def open_(specs: tuple[str, ...], standings: bool, last: bool) -> None:
379
+ """Open the problem (or contest) here in the browser."""
380
+ _open(specs, standings, last)
381
+
382
+
383
+ @click.command(hidden=True)
384
+ @click.argument("specs", nargs=-1)
385
+ @cf_command
386
+ def stand(specs: tuple[str, ...]) -> None:
387
+ """cf open --standings (cf-tool 0.3's name)."""
388
+ _open(specs, True, False)
389
+
390
+
391
+ @click.command(hidden=True)
392
+ @click.argument("specs", nargs=-1)
393
+ @cf_command
394
+ def sid(specs: tuple[str, ...]) -> None:
395
+ """cf open --last (cf-tool 0.3's name)."""
396
+ _open(specs, False, True)
397
+
398
+
399
+ COMMANDS = [get, race, new, listen, pick, problems, contests, open_, stand, sid]
cf_tool/cli/setup.py ADDED
@@ -0,0 +1,195 @@
1
+ """Commands about cf-tool itself: config, langs, doctor, upgrade."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import platform
6
+ import re
7
+ import sys
8
+
9
+ import click
10
+ from rich import box
11
+ from rich.table import Table
12
+ from rich.text import Text
13
+
14
+ from cf_tool import __version__, languages, paths, ui
15
+ from cf_tool.cli import configure
16
+ from cf_tool.cli.common import cf_command, client, settings, short
17
+ from cf_tool.errors import CFError
18
+
19
+ REPOSITORY = "Fau818/cf-tool"
20
+
21
+
22
+ @click.command()
23
+ @click.option("--edit", "edit_it", is_flag=True, help="Open config.toml in your editor instead.")
24
+ @click.option("--path", "path_only", is_flag=True, help="Print where config.toml is, and stop.")
25
+ @cf_command
26
+ def config(edit_it: bool, path_only: bool) -> None:
27
+ """Change the settings, picking from lists: the language for new
28
+ problems, and for each language what it is submitted as, its compiler and
29
+ its starter code; the proxy; the login. They are saved in config.toml,
30
+ which --edit opens (every setting is explained in it)."""
31
+ path = paths.config_file()
32
+ if path_only:
33
+ click.echo(str(path)) # NOTE: never wrapped, for scripts
34
+ return
35
+ if edit_it:
36
+ configure.edit_file()
37
+ return
38
+ try:
39
+ settings()
40
+ except CFError as e:
41
+ ui.red(str(e))
42
+ if ui.interactive() and ui.confirm("Open it in your editor to fix it?", default=True):
43
+ configure.edit_file()
44
+ return
45
+ if not ui.interactive():
46
+ ui.plain(
47
+ f"cf config's menu needs a terminal. The settings are in {path} (cf config --edit)."
48
+ )
49
+ return
50
+ configure.menu()
51
+
52
+
53
+ @click.command()
54
+ @click.option(
55
+ "--all", "show_all", is_flag=True, help="Every language Codeforces offers, with its id."
56
+ )
57
+ @cf_command
58
+ def langs(show_all: bool) -> None:
59
+ """The languages: what builds and runs each here, what it submits as,
60
+ and its starter code."""
61
+ current = settings()
62
+ offered, live = client().languages() if show_all else (languages.known_languages(), False)
63
+ if show_all:
64
+ table = Table(box=box.ROUNDED, border_style=ui.MUTED, header_style=f"bold {ui.ACCENT}")
65
+ for column in ("id", "Codeforces language", "files"):
66
+ table.add_column(column)
67
+ for ext, group in sorted(
68
+ languages.by_family(offered).items(), key=lambda item: languages.family_name(item[0])
69
+ ):
70
+ for lid, name in group:
71
+ table.add_row(lid, Text(name), f".{ext}" if ext != "txt" else "")
72
+ ui.console.print(table)
73
+ if not live:
74
+ ui.yellow("Not logged in: this is the list read last (cf login reads the current one).")
75
+ return
76
+ table = Table(box=box.ROUNDED, border_style=ui.MUTED, header_style=f"bold {ui.ACCENT}")
77
+ for column in ("language", "files", "builds and runs with", "submits as", "starter code"):
78
+ table.add_column(column)
79
+ keys = list(dict.fromkeys([*configure.LISTED, *current.languages])) # NOTE: C: by its files
80
+ for key in keys:
81
+ language = languages.LANGUAGES.get(key)
82
+ files = " ".join(f".{ext}" for ext in (language.extensions if language else (key,)))
83
+ try:
84
+ setup = languages.setup(key, current, offered=offered)
85
+ tool = Text(setup.tool)
86
+ submits = Text(
87
+ setup.submit[1] if setup.submit else "not offered",
88
+ style="" if setup.submit else ui.WARNING,
89
+ )
90
+ except CFError as e:
91
+ hint = str(e).split(". To get one: ")
92
+ tool = Text(f"missing: {hint[-1]}" if len(hint) > 1 else str(e), style=ui.WARNING)
93
+ submits = Text("")
94
+ try:
95
+ _, origin = languages.starter(key, current)
96
+ starter = "built-in" if origin == "built-in" else short(origin)
97
+ except CFError:
98
+ starter = "missing"
99
+ name = languages.display_name(key) + (" *" if key == current.language else "")
100
+ table.add_row(
101
+ Text(name, style="bold" if key == current.language else ""),
102
+ files,
103
+ tool,
104
+ submits,
105
+ Text(starter),
106
+ )
107
+ ui.console.print(table)
108
+ ui.dim(f"* cf get's language. Change any of these in {short(current.file)} (cf config).")
109
+ ui.dim("cf langs --all lists the languages Codeforces offers.")
110
+
111
+
112
+ _MARKS = {"ok": ("✓ ", "green"), "warn": ("! ", "yellow"), "error": ("✗ ", "red")}
113
+
114
+
115
+ @click.command()
116
+ @cf_command
117
+ def doctor() -> None:
118
+ """Check the setup (languages, compilers, login, Chrome) and say how to fix it."""
119
+ from cf_tool import doctor as checks
120
+
121
+ ui.plain(f"cf-tool {__version__}, Python {platform.python_version()}, {sys.platform}")
122
+ try:
123
+ findings = checks.run(settings(), client())
124
+ except CFError as e: # NOTE: a broken settings file is a finding too
125
+ findings = [checks.Finding(checks.ERROR, str(e))]
126
+ for finding in findings:
127
+ ui.line(_MARKS[finding.level], (finding.text, ""))
128
+ if finding.fix:
129
+ ui.line((" " + finding.fix, "cyan"))
130
+ if any(finding.level == checks.ERROR for finding in findings):
131
+ sys.exit(1)
132
+
133
+
134
+ _VERSION_RE = re.compile(r"v?(\d+(?:\.\d+)*)(?:[-._]?(a|alpha|b|beta|c|rc)[-._]?(\d*))?", re.I)
135
+ _PRE_RANK = {"a": 0, "alpha": 0, "b": 1, "beta": 1, "c": 2, "rc": 2}
136
+
137
+
138
+ def version_key(version: str) -> tuple[int, ...] | None:
139
+ """Sortable form of "0.3.0", "v0.3.0a1", "0.3.0-rc.2" (PEP 440 order:
140
+ 0.3.0a1 < 0.3.0b1 < 0.3.0rc1 < 0.3.0); None if it is not a version."""
141
+ match = _VERSION_RE.fullmatch(version.strip())
142
+ if not match:
143
+ return None
144
+ release = [int(n) for n in match.group(1).split(".")]
145
+ release += [0] * (4 - len(release))
146
+ tag = (match.group(2) or "").lower()
147
+ return (*release, _PRE_RANK.get(tag, 3), int(match.group(3) or 0))
148
+
149
+
150
+ @click.command()
151
+ @cf_command
152
+ def upgrade() -> None:
153
+ """Check GitHub for a newer cf-tool release."""
154
+ from curl_cffi import requests as curl_requests
155
+
156
+ ui.cyan(f"Current version is {__version__}")
157
+ url = f"https://api.github.com/repos/{REPOSITORY}/releases?per_page=30"
158
+ try:
159
+ response = curl_requests.get(
160
+ url,
161
+ timeout=10,
162
+ # NOTE: GitHub's API refuses requests without a User-Agent
163
+ headers={
164
+ "User-Agent": f"cf-tool/{__version__}",
165
+ "Accept": "application/vnd.github+json",
166
+ },
167
+ )
168
+ except Exception as e:
169
+ raise CFError(f"Cannot reach GitHub: {e}") from e
170
+ if response.status_code != 200:
171
+ raise CFError(f"Cannot list the releases of {REPOSITORY} ({response.status_code})")
172
+ current = version_key(__version__) or ()
173
+ on_prerelease = current[-2:-1] != (3,)
174
+ # NOTE: pre-releases are offered only to those already running one
175
+ tags = [
176
+ str(release.get("tag_name", ""))
177
+ for release in response.json()
178
+ if not release.get("draft") and (on_prerelease or not release.get("prerelease"))
179
+ ]
180
+ versions = [(key, tag) for tag in tags if (key := version_key(tag))]
181
+ if not versions:
182
+ raise CFError(f"No release of {REPOSITORY} is published on GitHub yet")
183
+ latest_key, latest = max(versions)
184
+ if latest_key <= current:
185
+ ui.green("You already have the latest version")
186
+ return
187
+ ui.green(f"Latest version is {latest}. Upgrade with:")
188
+ if latest_key[-2] == 3: # NOTE: a release, not a pre-release: on PyPI
189
+ ui.plain(" uv tool upgrade cf-tool-cli (or: pipx upgrade cf-tool-cli)")
190
+ ui.plain("Installed from GitHub:")
191
+ ui.plain(f" uv tool install --force git+https://github.com/{REPOSITORY}@{latest}")
192
+ ui.plain(f" (or: pipx install --force git+https://github.com/{REPOSITORY}@{latest})")
193
+
194
+
195
+ COMMANDS = [config, langs, doctor, upgrade]