coho-cli 2026.1.0a21__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.
coho_cli/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """coho_cli — the ``coho`` command. A thin layer over ``coho_management_sdk``."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("coho-cli")
7
+ except PackageNotFoundError: # pragma: no cover - editable/dev installs
8
+ __version__ = "0.0.0"
coho_cli/_entry_ids.py ADDED
@@ -0,0 +1,164 @@
1
+ """``@<slug>`` wherever a command takes an entry id: the slug is looked up on the same
2
+ ref, and the command carries on with the id as if it had been typed.
3
+
4
+ The lookup is the delivery contract's ``entries-by-slug/{slug}`` read, which the BFF
5
+ serves live for a ref under ``preview/`` — one request at depth 0, the entry alone. The
6
+ management API has no slug filter on ``entries``, and paging through every entry to find
7
+ one is not a lookup.
8
+
9
+ What that read can and cannot answer:
10
+
11
+ - Slugs are unique per ref among every node's slugs (coho-data D24), so a slug names at
12
+ most one node. That node may be a content type or an asset rather than an entry, and
13
+ is refused as one.
14
+ - A deleted entry has no slug on the ref any more: its id still reaches its history.
15
+ - Live reads serve a branch, or an environment on one. A tag is refused as a ref that
16
+ does not exist, so on a tag the id is the only way in.
17
+
18
+ Entry ids are UUIDs, so no id starts with ``@`` and the prefix cannot shadow one. A UUID
19
+ is a well-formed slug too, so ``@<uuid>`` is looked up as a slug like any other.
20
+
21
+ The same goes for the value of a reference field in ``--set``: ``author=@priya-shah``,
22
+ ``tags=["@traffic","@drainage"]``, and of an asset field, where the slug is an asset's:
23
+ ``image=@culvert``, ``downloads=["@guide"]``. Only reference and asset fields, as the
24
+ entry's content type declares them, are looked up, so a text field may still hold
25
+ ``@handle``. The server then checks the ids as it checks any: that each exists and is of
26
+ a type, or a media type, the field allows.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import re
32
+ from collections.abc import Callable
33
+ from typing import Any
34
+ from uuid import UUID
35
+
36
+ from coho_management_sdk import NotFound, Ref
37
+ from coho_management_sdk.errors import RefNotFound
38
+ from coho_management_sdk.transport import segment
39
+
40
+ from ._state import Usage
41
+
42
+ # A content slug, as `_slugs.content_slug` makes them and the server checks them.
43
+ _CONTENT_SLUG = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
44
+
45
+
46
+ def entry_id(ref: Ref, raw: str) -> str:
47
+ """The entry id ``raw`` names on ``ref``: itself, or for ``@<slug>`` the id of the
48
+ entry with that slug. Pass the ``Ref`` the command then acts on, so the slug is
49
+ looked up where the id will be used."""
50
+ if not raw.startswith("@"):
51
+ return raw
52
+ slug = raw[1:]
53
+ if not _CONTENT_SLUG.match(slug):
54
+ raise Usage(
55
+ f"{raw!r} is not an entry slug: after `@` comes a slug such as @hello-world. "
56
+ "An entry id is a UUID and is passed without the `@`."
57
+ )
58
+ preview = ref.preview()
59
+ try:
60
+ # ⚠️ The SDK's Preview has no by-slug read yet; this is the one request it would
61
+ # make. Move it to `preview.entry_by_slug(slug)` once the SDK has one.
62
+ body = preview._t.get(f"{preview.path}/entries-by-slug/{segment(slug)}").body or {}
63
+ except RefNotFound:
64
+ raise Usage(
65
+ f"cannot look up {raw} on {ref.name}: slugs resolve on a branch, or an "
66
+ "environment on one, and this ref is a tag or does not exist. Use the entry id."
67
+ ) from None
68
+ except NotFound as exc:
69
+ # ⚠️ The BFF answers every 404 it relays as ROUTE_NOT_FOUND for now, its own
70
+ # status page replacing authoring's problem, so a missing slug and a tag both
71
+ # arrive as that. Read as either until the BFF relays the code it is given.
72
+ if exc.code not in ("ENTRY_NOT_FOUND", "ROUTE_NOT_FOUND"):
73
+ raise
74
+ # A UUID is a well-formed slug too, so `@<id>` is looked up rather than refused.
75
+ hint = (
76
+ f"To use {slug} as an entry id, drop the `@`."
77
+ if _is_uuid(slug)
78
+ else "`coho entry list` shows the slugs."
79
+ )
80
+ if exc.code == "ROUTE_NOT_FOUND":
81
+ hint += " On a tag, slugs do not resolve: use the entry id."
82
+ raise NotFound(
83
+ f"no entry with slug '{slug}' on {ref.name}. {hint}",
84
+ code="ENTRY_NOT_FOUND",
85
+ status=404,
86
+ ) from None
87
+ roots = body.get("roots") or []
88
+ if len(roots) != 1: # pragma: no cover - slugs are unique per ref (D24)
89
+ raise Usage(f"{raw} names {len(roots)} nodes on {ref.name}. Use the entry id.")
90
+ node = (body.get("entries") or {}).get(roots[0]) or {}
91
+ # Only an entry has a content type. A type's or an asset's slug comes back without one.
92
+ if not node.get("type"):
93
+ raise Usage(
94
+ f"{raw} on {ref.name} is a content type or an asset, not an entry. "
95
+ "Slugs are unique across all three."
96
+ )
97
+ return str(roots[0])
98
+
99
+
100
+ def reference_ids(
101
+ ref: Ref, type_of: Callable[[], str], sets: list[tuple[str, Any]]
102
+ ) -> list[tuple[str, Any]]:
103
+ """``--set`` values, each ``@<slug>`` in a reference or asset field replaced by the id
104
+ of the entry, or the asset, with that slug on ``ref``. ``type_of`` gives the entry's
105
+ content type, and is only called, and the type only read, when some value names a
106
+ slug: otherwise nothing is requested."""
107
+ if not any(_names_slug(value) for _, value in sets):
108
+ return sets
109
+ # Effective, so a field the type inherits, or takes from a trait, is found too: a
110
+ # subtype's `author=@priya-shah` names its parent's reference field.
111
+ kinds = {
112
+ f.get("id"): f.get("type")
113
+ for f in ref.types.get(type_of(), effective=True).fields
114
+ if isinstance(f, dict) and f.get("type") in ("reference", "asset")
115
+ }
116
+ found: dict[str, str] = {}
117
+ assets: dict[str, str] | None = None
118
+
119
+ def asset_id(raw: str) -> str:
120
+ # Assets are listed once, by the first asset slug named: the management API has
121
+ # no by-slug read for them, and a page of 1000 covers most projects in one request.
122
+ nonlocal assets
123
+ if assets is None:
124
+ assets = {a.slug: a.id for a in ref.assets.iterate(page_size=1000)}
125
+ slug = raw[1:]
126
+ if slug not in assets:
127
+ raise NotFound(
128
+ f"no asset with slug '{slug}' on {ref.name}. `coho asset list` shows the slugs.",
129
+ code="ASSET_NOT_FOUND",
130
+ status=404,
131
+ )
132
+ return assets[slug]
133
+
134
+ def resolve(kind: Any, value: Any) -> Any:
135
+ if not (isinstance(value, str) and value.startswith("@")):
136
+ return value
137
+ key = f"{kind}:{value}"
138
+ if key not in found:
139
+ found[key] = asset_id(value) if kind == "asset" else entry_id(ref, value)
140
+ return found[key]
141
+
142
+ out: list[tuple[str, Any]] = []
143
+ for path, value in sets:
144
+ kind = kinds.get(path.split(".")[0])
145
+ if kind is not None:
146
+ if isinstance(value, list):
147
+ value = [resolve(kind, v) for v in value]
148
+ else:
149
+ value = resolve(kind, value)
150
+ out.append((path, value))
151
+ return out
152
+
153
+
154
+ def _names_slug(value: Any) -> bool:
155
+ values = value if isinstance(value, list) else [value]
156
+ return any(isinstance(v, str) and v.startswith("@") for v in values)
157
+
158
+
159
+ def _is_uuid(value: str) -> bool:
160
+ try:
161
+ UUID(value)
162
+ except ValueError:
163
+ return False
164
+ return True
coho_cli/_io.py ADDED
@@ -0,0 +1,55 @@
1
+ """Reading JSON from files, stdin or inline strings — for --file and --fields."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from ._state import Usage
11
+
12
+
13
+ def read_json(source: str | None, *, what: str = "JSON") -> Any:
14
+ """``-`` reads stdin; a path reads a file; anything starting with ``{`` or ``[`` is inline."""
15
+ if source is None:
16
+ raise Usage(f"{what} is required")
17
+ text: str
18
+ if source == "-":
19
+ text = sys.stdin.read()
20
+ elif source.lstrip().startswith(("{", "[")):
21
+ text = source
22
+ else:
23
+ path = Path(source)
24
+ if not path.exists():
25
+ raise Usage(f"{what}: no such file: {source}")
26
+ text = path.read_text()
27
+ try:
28
+ return json.loads(text)
29
+ except ValueError as exc:
30
+ raise Usage(f"{what} is not valid JSON: {exc}") from exc
31
+
32
+
33
+ def set_path(doc: dict[str, Any], dotted: str, value: Any) -> None:
34
+ """Set ``a.b.c`` in a nested dict, creating levels. Used by ``--set``."""
35
+ parts = dotted.split(".")
36
+ cur = doc
37
+ for p in parts[:-1]:
38
+ nxt = cur.get(p)
39
+ if not isinstance(nxt, dict):
40
+ nxt = {}
41
+ cur[p] = nxt
42
+ cur = nxt
43
+ cur[parts[-1]] = value
44
+
45
+
46
+ def parse_set(spec: str) -> tuple[str, Any]:
47
+ """``path=value``; the value is JSON if it parses, else a string."""
48
+ if "=" not in spec:
49
+ raise Usage(f"--set needs path=value, got '{spec}'")
50
+ path, raw = spec.split("=", 1)
51
+ try:
52
+ value: Any = json.loads(raw)
53
+ except ValueError:
54
+ value = raw
55
+ return path.strip(), value
coho_cli/_output.py ADDED
@@ -0,0 +1,103 @@
1
+ """Printing: tables for people, JSON for scripts, and the shown-once warnings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from collections.abc import Iterable, Sequence
8
+ from typing import Any
9
+
10
+ from rich.console import Console
11
+ from rich.table import Table
12
+
13
+ from ._state import State
14
+
15
+ console = Console()
16
+ err = Console(stderr=True)
17
+
18
+
19
+ def emit_json(data: Any) -> None:
20
+ """Exactly what the contract describes, on stdout."""
21
+ sys.stdout.write(json.dumps(data, indent=2, ensure_ascii=False))
22
+ sys.stdout.write("\n")
23
+
24
+
25
+ def table(
26
+ state: State,
27
+ raw: Any,
28
+ columns: Sequence[str],
29
+ rows: Iterable[Sequence[Any]],
30
+ *,
31
+ title: str | None = None,
32
+ empty: str = "(none)",
33
+ ) -> None:
34
+ """Print ``rows`` as a table, or ``raw`` as JSON when ``--output json``."""
35
+ if state.output == "json":
36
+ emit_json(raw)
37
+ return
38
+ rows = list(rows)
39
+ if not rows:
40
+ console.print(f"[dim]{empty}[/dim]")
41
+ return
42
+ t = Table(title=title, show_lines=False, header_style="bold", box=None, pad_edge=False)
43
+ for c in columns:
44
+ t.add_column(c)
45
+ for r in rows:
46
+ t.add_row(*[_cell(v) for v in r])
47
+ console.print(t)
48
+
49
+
50
+ def record(state: State, raw: Any, pairs: Sequence[tuple[str, Any]]) -> None:
51
+ """One object as ``key value`` lines, or JSON."""
52
+ if state.output == "json":
53
+ emit_json(raw)
54
+ return
55
+ width = max((len(k) for k, _ in pairs), default=0)
56
+ for k, v in pairs:
57
+ console.print(f"[bold]{k.ljust(width)}[/bold] {_cell(v)}", highlight=False, markup=True)
58
+
59
+
60
+ def say(state: State, message: str) -> None:
61
+ """A one-line confirmation for humans; silent under ``--output json`` and ``--quiet``."""
62
+ if state.output != "json" and not state.quiet:
63
+ console.print(message, highlight=False)
64
+
65
+
66
+ def warn(message: str) -> None:
67
+ err.print(f"[yellow]warning:[/yellow] {message}", highlight=False)
68
+
69
+
70
+ def secret_once(
71
+ state: State, raw: Any, label: str, value: str, extra: Sequence[tuple[str, Any]] = ()
72
+ ) -> None:
73
+ """Print a value that the server will never show again.
74
+
75
+ In JSON mode the whole response is printed (scripts need the field); in table mode
76
+ the value is printed on its own line, unmarked-up, so it can be copied, with a
77
+ warning on stderr so the warning is never captured into a file.
78
+ """
79
+ if state.output == "json":
80
+ emit_json(raw)
81
+ else:
82
+ for k, v in extra:
83
+ console.print(f"[bold]{k}[/bold] {_cell(v)}", highlight=False)
84
+ console.print(f"[bold]{label}[/bold]")
85
+ sys.stdout.write(value + "\n")
86
+ sys.stdout.flush()
87
+ err.print(f"[yellow]This {label.lower()} is shown once and cannot be retrieved again.[/yellow]")
88
+
89
+
90
+ def _cell(value: Any) -> str:
91
+ if value is None:
92
+ return "[dim]-[/dim]"
93
+ if isinstance(value, bool):
94
+ return "yes" if value else "no"
95
+ if isinstance(value, list | tuple):
96
+ return ", ".join(_cell(v) for v in value) if value else "[dim]-[/dim]"
97
+ if isinstance(value, dict):
98
+ return json.dumps(value, ensure_ascii=False)
99
+ return str(value)
100
+
101
+
102
+ def short(value: str | None, n: int = 8) -> str:
103
+ return (value or "")[:n]
coho_cli/_slugs.py ADDED
@@ -0,0 +1,59 @@
1
+ """Build slugs from names. The API checks them; this is the one place they are made.
2
+
3
+ A type slug is camelCase (``Blog post`` -> ``blogPost``) because it appears in code:
4
+ ``coho entry create -t blogPost`` and ``of: ["teamMember"]``. A content slug is
5
+ kebab-case (``Hello, World!`` -> ``hello-world``) because it appears in URLs.
6
+
7
+ These rules mirror ``coho.model.Slugs`` on the server, which stays the authority: a
8
+ generated slug that the server would refuse is refused here first, with a message
9
+ that says what to pass instead.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ import unicodedata
16
+
17
+ from ._state import Usage
18
+
19
+ _TYPE_SLUG = re.compile(r"^[a-z][a-zA-Z0-9]*$")
20
+ _CONTENT_SLUG = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
21
+
22
+
23
+ def _words(name: str) -> list[str]:
24
+ # Accents fold to their base letter (Über -> Uber). Anything else that is not
25
+ # ASCII letter or digit is a separator, so punctuation and spacing both break words.
26
+ folded = unicodedata.normalize("NFKD", name).encode("ascii", "ignore").decode("ascii")
27
+ return re.findall(r"[A-Za-z0-9]+", folded)
28
+
29
+
30
+ def type_slug(name: str) -> str:
31
+ """camelCase from a name: the first word lowercased, each later word capitalised."""
32
+ words = _words(name)
33
+ if not words:
34
+ raise Usage(
35
+ f"cannot build a type slug from {name!r}: it has no letters or digits. "
36
+ "Pass the slug explicitly."
37
+ )
38
+ head, *tail = words
39
+ slug = head.lower() + "".join(w[:1].upper() + w[1:].lower() for w in tail)
40
+ if not _TYPE_SLUG.match(slug):
41
+ raise Usage(
42
+ f"cannot build a type slug from {name!r}: it must start with a letter. "
43
+ "Pass the slug explicitly."
44
+ )
45
+ return slug
46
+
47
+
48
+ def content_slug(name: str) -> str:
49
+ """kebab-case from a name: every word lowercased and joined with hyphens."""
50
+ words = _words(name)
51
+ if not words:
52
+ raise Usage(
53
+ f"cannot build a content slug from {name!r}: it has no letters or digits. "
54
+ "Pass --slug explicitly."
55
+ )
56
+ slug = "-".join(w.lower() for w in words)
57
+ if not _CONTENT_SLUG.match(slug): # pragma: no cover - the rule holds by construction
58
+ raise Usage(f"cannot build a content slug from {name!r}. Pass --slug explicitly.")
59
+ return slug
coho_cli/_state.py ADDED
@@ -0,0 +1,205 @@
1
+ """What every command needs: the config, the profile, a client, and the context.
2
+
3
+ Resolution order for the three context values (account, project, ref):
4
+ ``--account/--project/--ref`` flags → ``COHO_ACCOUNT/COHO_PROJECT/COHO_REF`` →
5
+ the profile's saved context (``coho use``). A missing value is a usage error that
6
+ names the flag and the ``use`` command, not a stack trace.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from functools import cached_property
13
+ from uuid import UUID
14
+
15
+ import typer
16
+ from coho_management_sdk import Account, Coho, Config, Profile, Project, Ref
17
+ from coho_management_sdk.models import Membership
18
+ from coho_management_sdk.profiles import Context
19
+
20
+ EXIT_ERROR = 1
21
+ EXIT_USAGE = 2
22
+ EXIT_AUTH = 3
23
+ EXIT_CONFLICT = 4
24
+ EXIT_NOT_FOUND = 5
25
+
26
+
27
+ class Usage(Exception):
28
+ """A usage error: printed as one line by ``main()``, exit code 2, no traceback."""
29
+
30
+ exit_code = EXIT_USAGE
31
+
32
+ def __init__(self, message: str) -> None:
33
+ super().__init__(message)
34
+ self.message = message
35
+
36
+
37
+ @dataclass
38
+ class State:
39
+ profile_name: str | None = None
40
+ output: str = "table"
41
+ url: str | None = None
42
+ overrides: Context = field(default_factory=Context)
43
+ quiet: bool = False
44
+ yes: bool = False
45
+ # Set when the account came from the saved context rather than `--account`, so an
46
+ # ACCOUNT_NOT_FOUND can say the saved one may be gone: deleted, or a reset environment.
47
+ used_saved_account: bool = field(default=False, init=False)
48
+ # What to say after a 403, when the command knows better than the server's message
49
+ # what the person can do instead.
50
+ forbidden_hint: str | None = field(default=None, init=False)
51
+
52
+ @cached_property
53
+ def config(self) -> Config:
54
+ return Config.load()
55
+
56
+ @cached_property
57
+ def profile(self) -> Profile:
58
+ try:
59
+ return self.config.profile(self.profile_name)
60
+ except KeyError as exc:
61
+ name = exc.args[0]
62
+ raise Usage(
63
+ f"no profile named '{name}'. Create it: coho configure --profile {name} --url https://…"
64
+ ) from exc
65
+
66
+ @property
67
+ def context(self) -> Context:
68
+ saved = self.profile.context.with_env()
69
+ return Context(
70
+ account=self.overrides.account or saved.account,
71
+ project=self.overrides.project or saved.project,
72
+ ref=self.overrides.ref or saved.ref,
73
+ )
74
+
75
+ @cached_property
76
+ def coho(self) -> Coho:
77
+ url = self.url or self.config.effective_url(self.profile)
78
+ if not url:
79
+ raise Usage(
80
+ f"profile '{self.profile.name}' has no url. Set one: "
81
+ f"coho configure --profile {self.profile.name} --url https://…"
82
+ )
83
+ return Coho(url, profile=self.profile)
84
+
85
+ # -- context resolution ----------------------------------------------------
86
+
87
+ def account(self) -> Account:
88
+ # Named on the command line or in COHO_ACCOUNT: used as given, never second-guessed.
89
+ if self.overrides.account:
90
+ return self.coho.account(self.overrides.account)
91
+ name = self.context.account
92
+ # ⚠️ A saved account can outlive the account: deleted, or an environment reset. Checked
93
+ # against `/me` (one request, cached for the command) so a person with one account is
94
+ # not stuck on one they no longer have. Not with a project token: it has no `/me`.
95
+ if name and not self.coho.uses_project_token:
96
+ memberships = self.coho.accounts()
97
+ if not any(name in (m.account_id, m.account_name) for m in memberships):
98
+ return self._instead_of_gone(name, memberships)
99
+ if name:
100
+ self.used_saved_account = True
101
+ return self.coho.account(name)
102
+ # No account chosen. Someone in exactly one account has nothing to choose,
103
+ # so that one is used; with several, guessing would act on the wrong one.
104
+ memberships = self.coho.accounts()
105
+ if len(memberships) == 1:
106
+ return self.coho.account(memberships[0].account_id)
107
+ if not memberships:
108
+ raise Usage(
109
+ "you are not in any account yet. Create one with `coho signup`, "
110
+ "or ask an account admin to invite you."
111
+ )
112
+ names = ", ".join(sorted(m.account_name for m in memberships))
113
+ raise Usage(
114
+ f"you are in {len(memberships)} accounts ({names}). "
115
+ "Choose one with `coho use <account>` or pass --account."
116
+ )
117
+
118
+ def _instead_of_gone(self, gone: str, memberships: list[Membership]) -> Account:
119
+ """The saved account is not one of the caller's: their only one if they have one,
120
+ saved in its place, or a usage error that says what to choose."""
121
+ from ._output import warn
122
+
123
+ # Only ever the profile's: `--account` and COHO_ACCOUNT (its envvar) are used as given.
124
+ p = self.profile.name
125
+ if not memberships:
126
+ raise Usage(
127
+ "you are not in any account yet. Create one with `coho signup`, "
128
+ "or ask an account admin to invite you."
129
+ )
130
+ if len(memberships) > 1:
131
+ names = ", ".join(sorted(m.account_name for m in memberships))
132
+ raise Usage(
133
+ f"the account saved for profile '{p}' is not one of yours; it may have been "
134
+ f"deleted. You are in {len(memberships)} accounts ({names}): choose one with "
135
+ "`coho use <account>`."
136
+ )
137
+ only = memberships[0]
138
+ # As `coho use` does when the account changes: the project and ref were the gone account's.
139
+ self.profile.context.account = only.account_id
140
+ self.profile.context.project = None
141
+ self.profile.context.ref = None
142
+ self.save()
143
+ warn(
144
+ f"the account saved for profile '{p}' is not one of yours; it may have been "
145
+ f"deleted. Using {only.account_name}, your only account, from now on."
146
+ )
147
+ return self.coho.account(only.account_id)
148
+
149
+ def project(self) -> Project:
150
+ account = self.account()
151
+ raw = self.context.project
152
+ if not raw:
153
+ raise Usage(
154
+ "no project in context. Run `coho use <account> <project>` or pass --project."
155
+ )
156
+ return account.project(self.resolve_project_id(account, raw))
157
+
158
+ def resolve_project_id(self, account: Account, raw: str) -> str:
159
+ """A project id from an id, a locally remembered name, or a name on the server.
160
+
161
+ The local registry answers first and without a request; an id passes
162
+ through; anything else is matched by name against the projects the server
163
+ lists for you. Two projects with the same name are refused rather than
164
+ guessed between.
165
+ """
166
+ known = self.profile.resolve_project(account.id, raw)
167
+ if known != raw or _is_uuid(raw):
168
+ return known
169
+ matches = [p for p in account.projects.list() if p.name.casefold() == raw.casefold()]
170
+ if len(matches) == 1:
171
+ return matches[0].id
172
+ if not matches:
173
+ raise Usage(f"no project named '{raw}' that you can open. See `coho project list`.")
174
+ ids = ", ".join(p.id for p in matches)
175
+ raise Usage(f"{len(matches)} projects are named '{raw}' ({ids}). Use the id.")
176
+
177
+ def ref(self) -> Ref:
178
+ project = self.project()
179
+ name = self.context.ref
180
+ if not name:
181
+ raise Usage("no ref in context. Run `coho ref switch <ref>` or pass --ref.")
182
+ return project.ref(name)
183
+
184
+ def save(self) -> None:
185
+ self.config.save()
186
+
187
+
188
+ # The running command's state, for `main` to read once an error has escaped the command.
189
+ current: State | None = None
190
+
191
+
192
+ def get_state(ctx: typer.Context) -> State:
193
+ state = ctx.find_root().obj
194
+ if not isinstance(state, State): # pragma: no cover - typer wiring
195
+ state = State()
196
+ ctx.find_root().obj = state
197
+ return state
198
+
199
+
200
+ def _is_uuid(value: str) -> bool:
201
+ try:
202
+ UUID(value)
203
+ except ValueError:
204
+ return False
205
+ return True