hyperspell-brain 0.4.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.
hyperbrain/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """hyperbrain — an agent-first CLI for the Hyperspell company brain."""
2
+
3
+ __version__ = "0.4.0"
hyperbrain/cli.py ADDED
@@ -0,0 +1,141 @@
1
+ """hyperbrain — agent-first CLI for the Hyperspell company brain.
2
+
3
+ Root command: wires global options into a per-invocation AppCtx, then dispatches
4
+ to the command modules. Designed so an agent can call any subcommand
5
+ non-interactively and parse JSON from stdout.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Optional
11
+
12
+ import typer
13
+
14
+ from . import __version__, config
15
+ from .context import AppCtx
16
+ from .output import FORMAT_OPTION, Format, emit, pick, set_output_options
17
+ from .commands import api as api_cmd
18
+ from .commands import ask as ask_cmd
19
+ from .commands import auth as auth_cmd
20
+ from .commands import brain as brain_cmd
21
+ from .commands import completion as completion_cmd
22
+ from .commands import config as config_cmd
23
+ from .commands import connections as connections_cmd
24
+ from .commands import doctor as doctor_cmd
25
+ from .commands import guide as guide_cmd
26
+ from .commands import integrations as integrations_cmd
27
+ from .commands import login as login_cmd
28
+ from .commands import memories as memories_cmd
29
+ from .commands import remember as remember_cmd
30
+ from .commands import search as search_cmd
31
+ from .commands import structure as structure_cmd
32
+ from .commands import update as update_cmd
33
+
34
+ app = typer.Typer(
35
+ name="hyperbrain",
36
+ help="Agent-first CLI for the Hyperspell company brain. JSON by default; pipe-friendly.",
37
+ no_args_is_help=True,
38
+ add_completion=False,
39
+ context_settings={"help_option_names": ["-h", "--help"]},
40
+ )
41
+
42
+
43
+ def _version(value: bool) -> None:
44
+ if value:
45
+ typer.echo(__version__)
46
+ raise typer.Exit()
47
+
48
+
49
+ @app.callback()
50
+ def main(
51
+ ctx: typer.Context,
52
+ api_key: Optional[str] = typer.Option(
53
+ None, "--api-key", envvar="HYPERSPELL_API_KEY", help="API key (or device JWT)."
54
+ ),
55
+ api_url: Optional[str] = typer.Option(
56
+ None, "--api-url", envvar="HYPERSPELL_BASE_URL", help="API base URL."
57
+ ),
58
+ as_user: Optional[str] = typer.Option(
59
+ None, "--as-user", help="Act as this user (X-As-User; API-key auth only)."
60
+ ),
61
+ output_format: Format = typer.Option(
62
+ Format.AUTO,
63
+ "--format",
64
+ "-o",
65
+ help="Output format: auto (table on TTY, else json), json, table.",
66
+ ),
67
+ fields: Optional[str] = typer.Option(
68
+ None,
69
+ "--fields",
70
+ help="Comma-separated top-level keys to keep in the output (token economy).",
71
+ ),
72
+ quiet: bool = typer.Option(
73
+ False,
74
+ "--quiet",
75
+ "-q",
76
+ help="Suppress the stdout data channel; branch on the exit code instead.",
77
+ ),
78
+ _v: bool = typer.Option(
79
+ False, "--version", callback=_version, is_eager=True, help="Show version and exit."
80
+ ),
81
+ ) -> None:
82
+ """Resolve credentials/endpoint once and stash them for subcommands."""
83
+ set_output_options(fields=fields, quiet=quiet)
84
+ ctx.obj = AppCtx(
85
+ resolved=config.resolve(api_key=api_key, api_url=api_url, as_user=as_user),
86
+ fmt=output_format,
87
+ )
88
+
89
+
90
+ # Top-level verbs.
91
+ app.command()(login_cmd.login)
92
+ app.command()(ask_cmd.ask)
93
+ app.command()(search_cmd.search)
94
+ app.command()(remember_cmd.remember)
95
+ app.command(name="api")(api_cmd.api)
96
+ app.command()(update_cmd.update)
97
+ app.command()(doctor_cmd.doctor)
98
+ app.command()(completion_cmd.completion)
99
+ app.command(name="help")(guide_cmd.help)
100
+
101
+ # Grouped nouns.
102
+ app.add_typer(memories_cmd.app, name="memories")
103
+ app.add_typer(connections_cmd.app, name="connections")
104
+ app.add_typer(integrations_cmd.app, name="integrations")
105
+ app.add_typer(brain_cmd.app, name="brain")
106
+ app.add_typer(structure_cmd.app, name="structure")
107
+ app.add_typer(config_cmd.app, name="config")
108
+ app.add_typer(auth_cmd.app, name="auth")
109
+
110
+
111
+ def _describe(cmd: Any, name: str) -> dict[str, Any]:
112
+ """Recursively describe a click command tree using duck-typing (no click import)."""
113
+ node: dict[str, Any] = {"name": name, "help": (getattr(cmd, "help", "") or "").strip()}
114
+ params = []
115
+ for p in getattr(cmd, "params", []):
116
+ kind = getattr(p, "param_type_name", "parameter") # 'option' | 'argument'
117
+ entry: dict[str, Any] = {"name": p.name, "kind": kind, "required": bool(p.required)}
118
+ if kind == "option":
119
+ entry["flags"] = list(getattr(p, "opts", []))
120
+ entry["help"] = getattr(p, "help", "") or ""
121
+ params.append(entry)
122
+ if params:
123
+ node["params"] = params
124
+ subcommands = getattr(cmd, "commands", None) # dict on click Groups
125
+ if isinstance(subcommands, dict):
126
+ node["commands"] = [_describe(sub, sub_name) for sub_name, sub in subcommands.items()]
127
+ return node
128
+
129
+
130
+ @app.command()
131
+ def schema(
132
+ ctx: typer.Context,
133
+ fmt: Optional[Format] = FORMAT_OPTION,
134
+ ) -> None:
135
+ """Dump the full command tree as JSON, so an agent can introspect capabilities."""
136
+ root = typer.main.get_command(app)
137
+ emit(_describe(root, "hyperbrain"), pick(ctx, fmt))
138
+
139
+
140
+ if __name__ == "__main__":
141
+ app()
hyperbrain/client.py ADDED
@@ -0,0 +1,93 @@
1
+ """SDK client construction + a raw-request escape hatch.
2
+
3
+ Most commands use the typed ``hyperspell`` SDK. A handful of endpoints the SDK
4
+ doesn't model yet — context-document *trees* and the ``/admin/*`` surface — are
5
+ reached through the SDK's own underlying httpx client via :func:`raw`, so they
6
+ share the same auth, base URL, retries, and timeouts.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import contextlib
12
+ from typing import Any, Iterator
13
+
14
+ from hyperspell import (
15
+ APIConnectionError,
16
+ APIStatusError,
17
+ AuthenticationError,
18
+ Hyperspell,
19
+ HyperspellError,
20
+ NotFoundError,
21
+ )
22
+
23
+ from . import config
24
+ from .output import Exit, fail
25
+
26
+
27
+ def build(resolved: config.Resolved) -> Hyperspell:
28
+ """Construct an authenticated SDK client, or exit cleanly if unauthenticated."""
29
+ if not resolved.credential:
30
+ raise fail(
31
+ "No credential found. Pass --api-key, set HYPERSPELL_API_KEY, "
32
+ "or run `hyperbrain login` to populate ~/.hyperspell/config.toml.",
33
+ Exit.AUTH,
34
+ )
35
+ return Hyperspell(
36
+ api_key=resolved.credential,
37
+ user_id=resolved.user_id,
38
+ base_url=resolved.base_url,
39
+ )
40
+
41
+
42
+ def raw(
43
+ client: Hyperspell,
44
+ method: str,
45
+ path: str,
46
+ *,
47
+ body: dict[str, Any] | None = None,
48
+ params: dict[str, Any] | None = None,
49
+ ) -> Any:
50
+ """Call an endpoint the SDK doesn't model, reusing its auth + transport.
51
+
52
+ Returns parsed JSON (dict/list) and never raises for a non-2xx — callers get
53
+ the decoded error body so they can surface the API's own message.
54
+ """
55
+ options: dict[str, Any] = {"cast_to": object}
56
+ if body is not None:
57
+ options["body"] = body
58
+ if params is not None:
59
+ options["options"] = {"params": params}
60
+ verb = method.lower()
61
+ fn = getattr(client, verb, None)
62
+ if fn is None:
63
+ raise fail(f"Unsupported HTTP method: {method}", Exit.USAGE)
64
+ return fn(path, **options)
65
+
66
+
67
+ @contextlib.contextmanager
68
+ def api_errors() -> Iterator[None]:
69
+ """Translate SDK exceptions into structured stderr errors + stable exit codes."""
70
+ try:
71
+ yield
72
+ except AuthenticationError as exc:
73
+ raise fail(f"Authentication failed: {exc}", Exit.AUTH) from exc
74
+ except NotFoundError as exc:
75
+ raise fail(f"Not found: {exc}", Exit.NOT_FOUND) from exc
76
+ except APIStatusError as exc:
77
+ detail = _detail(exc)
78
+ raise fail(f"API error {exc.status_code}: {detail}", Exit.API) from exc
79
+ except APIConnectionError as exc:
80
+ raise fail(f"Could not reach the API: {exc}", Exit.API) from exc
81
+ except HyperspellError as exc:
82
+ raise fail(str(exc), Exit.ERROR) from exc
83
+
84
+
85
+ def _detail(exc: APIStatusError) -> str:
86
+ """Pull the API's own error message out of a non-2xx response when present."""
87
+ try:
88
+ body = exc.response.json()
89
+ except Exception:
90
+ return str(exc)
91
+ if isinstance(body, dict):
92
+ return str(body.get("detail") or body.get("error") or body)
93
+ return str(body)
File without changes
@@ -0,0 +1,55 @@
1
+ """Shared retrieval logic behind `brain ask` and `brain search`.
2
+
3
+ Both hit ``POST /memories/query``; ``ask`` flips ``answer=true`` and dials up
4
+ effort, while ``search`` returns ranked documents only. ``effort`` is passed via
5
+ ``extra_body`` because the pinned SDK predates that field — the API honors it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from hyperspell import Hyperspell
11
+
12
+ from .. import client as client_mod
13
+
14
+ VALID_EFFORT = {"minimal", "low", "medium", "high"}
15
+
16
+
17
+ def default_sources(client: Hyperspell) -> list[str]:
18
+ """Sources to query when the caller didn't pick any: everything this token has.
19
+
20
+ Reads the app's connected integrations from ``/auth/me`` (plus ``vault``,
21
+ which is always available) so "ask the brain" really means the whole brain.
22
+ The API drops any source it doesn't support, so a generous list is safe.
23
+ """
24
+ me = client_mod.raw(client, "GET", "/auth/me")
25
+ integs = []
26
+ if isinstance(me, dict):
27
+ integs = me.get("available_integrations") or []
28
+ return sorted({*integs, "vault"})
29
+
30
+
31
+ def run_query(
32
+ client: Hyperspell,
33
+ *,
34
+ query: str,
35
+ answer: bool,
36
+ effort: str,
37
+ sources: list[str] | None,
38
+ max_results: int,
39
+ collection: str | None,
40
+ ):
41
+ """Execute a query and return the SDK's QueryResult."""
42
+ options: dict[str, object] = {}
43
+ if collection:
44
+ options["filter"] = {"collection": collection}
45
+
46
+ kwargs: dict[str, object] = {
47
+ "query": query,
48
+ "answer": answer,
49
+ "max_results": max_results,
50
+ "sources": sources if sources else default_sources(client),
51
+ "extra_body": {"effort": effort},
52
+ }
53
+ if options:
54
+ kwargs["options"] = options
55
+ return client.memories.search(**kwargs)
@@ -0,0 +1,89 @@
1
+ """`hyperbrain api` — a raw authenticated request to any Hyperspell endpoint.
2
+
3
+ The escape hatch that lets an agent reach anything the API exposes, not just the
4
+ verbs we've modeled. Reuses the resolved credential + transport, returns parsed
5
+ JSON, and never invents arguments — you give it the method, path, and body.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import Any, Optional
14
+
15
+ import typer
16
+
17
+ from .. import client as client_mod
18
+ from .. import context, output
19
+
20
+ _METHODS = ("GET", "POST", "PATCH", "PUT", "DELETE")
21
+ # Methods that carry a request body (and may read it from stdin).
22
+ _BODY_METHODS = ("POST", "PATCH", "PUT", "DELETE")
23
+
24
+
25
+ def api(
26
+ ctx: typer.Context,
27
+ method: str = typer.Argument(..., help=f"HTTP method: {', '.join(_METHODS)}."),
28
+ path: str = typer.Argument(..., help="API path, e.g. /memories/list or /admin/apps/foo/..."),
29
+ data: Optional[str] = typer.Option(
30
+ None, "--data", "-d", help="JSON request body. Omit to read stdin (write methods)."
31
+ ),
32
+ file: Optional[Path] = typer.Option(
33
+ None, "--file", "-f", help="Read the JSON request body from a file."
34
+ ),
35
+ query: Optional[list[str]] = typer.Option(
36
+ None, "--query", "-q", help="Query param as key=value (repeatable)."
37
+ ),
38
+ fmt: Optional[output.Format] = output.FORMAT_OPTION,
39
+ ) -> None:
40
+ """Make a raw authenticated request to any endpoint and print the JSON response."""
41
+ verb = method.upper()
42
+ if verb not in _METHODS:
43
+ raise output.fail(f"Method must be one of {_METHODS}.", output.Exit.USAGE)
44
+ if not path.startswith("/"):
45
+ raise output.fail("Path must start with '/'.", output.Exit.USAGE)
46
+
47
+ body = _resolve_body(verb, data, file)
48
+ params = _parse_query(query)
49
+
50
+ app_ctx = context.get(ctx)
51
+ cli = client_mod.build(app_ctx.resolved)
52
+ with client_mod.api_errors():
53
+ result = client_mod.raw(cli, verb, path, body=body, params=params)
54
+ output.emit(result, output.pick(ctx, fmt))
55
+
56
+
57
+ def _resolve_body(verb: str, data: Optional[str], file: Optional[Path]) -> Optional[dict[str, Any]]:
58
+ raw_text: Optional[str] = None
59
+ if file is not None:
60
+ if not file.exists():
61
+ raise output.fail(f"File not found: {file}", output.Exit.NOT_FOUND)
62
+ raw_text = file.read_text()
63
+ elif data is not None:
64
+ raw_text = data
65
+ elif verb in _BODY_METHODS and not sys.stdin.isatty():
66
+ piped = sys.stdin.read()
67
+ raw_text = piped if piped.strip() else None
68
+
69
+ if raw_text is None:
70
+ return None
71
+ try:
72
+ parsed = json.loads(raw_text)
73
+ except json.JSONDecodeError as exc:
74
+ raise output.fail(f"Request body is not valid JSON: {exc}", output.Exit.USAGE) from exc
75
+ if not isinstance(parsed, dict):
76
+ raise output.fail("Request body must be a JSON object.", output.Exit.USAGE)
77
+ return parsed
78
+
79
+
80
+ def _parse_query(query: Optional[list[str]]) -> Optional[dict[str, str]]:
81
+ if not query:
82
+ return None
83
+ params: dict[str, str] = {}
84
+ for item in query:
85
+ key, sep, value = item.partition("=")
86
+ if not sep:
87
+ raise output.fail(f"--query '{item}' must be key=value.", output.Exit.USAGE)
88
+ params[key] = value
89
+ return params
@@ -0,0 +1,79 @@
1
+ """`brain ask` — ask the company brain a question and get a synthesized answer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from typing import Optional
7
+
8
+ import typer
9
+
10
+ from .. import client as client_mod
11
+ from .. import context, output
12
+ from ._query import VALID_EFFORT, run_query
13
+
14
+
15
+ def ask(
16
+ ctx: typer.Context,
17
+ query: str = typer.Argument(..., help="The question to ask the company brain."),
18
+ effort: str = typer.Option(
19
+ "high",
20
+ "--effort",
21
+ "-e",
22
+ help="Compute to spend: minimal | low | medium | high. Higher = better recall, more latency.",
23
+ ),
24
+ source: Optional[list[str]] = typer.Option(
25
+ None,
26
+ "--source",
27
+ "-s",
28
+ help="Restrict to these sources (repeatable). Default: all connected sources.",
29
+ ),
30
+ collection: Optional[str] = typer.Option(None, "--collection", help="Scope to a collection."),
31
+ max_results: int = typer.Option(10, "--max-results", "-n", help="Max source documents to use."),
32
+ answer_only: bool = typer.Option(
33
+ False, "--answer-only", help="Emit just the answer string, not the JSON envelope."
34
+ ),
35
+ fmt: Optional[output.Format] = output.FORMAT_OPTION,
36
+ ) -> None:
37
+ """Synthesize an answer from the brain, with the supporting documents."""
38
+ if effort not in VALID_EFFORT:
39
+ raise output.fail(
40
+ f"Invalid --effort {effort!r}; choose one of {sorted(VALID_EFFORT)}.",
41
+ output.Exit.USAGE,
42
+ )
43
+ app = context.get(ctx)
44
+ cli = client_mod.build(app.resolved)
45
+ with client_mod.api_errors():
46
+ result = run_query(
47
+ cli,
48
+ query=query,
49
+ answer=True,
50
+ effort=effort,
51
+ sources=source,
52
+ max_results=max_results,
53
+ collection=collection,
54
+ )
55
+ if answer_only:
56
+ typer.echo(getattr(result, "answer", None) or "")
57
+ return
58
+ resolved = output.pick(ctx, fmt)
59
+ if resolved is output.Format.TABLE or (resolved is output.Format.AUTO and sys.stdout.isatty()):
60
+ _render_answer(result)
61
+ else:
62
+ output.emit(result, resolved)
63
+
64
+
65
+ def _render_answer(result: object) -> None:
66
+ """Human-friendly terminal view: the prose answer plus its sources."""
67
+ from rich.console import Console
68
+ from rich.panel import Panel
69
+
70
+ console = Console()
71
+ answer = getattr(result, "answer", None) or "[dim](no answer returned)[/dim]"
72
+ console.print(Panel(answer, title="answer", border_style="cyan"))
73
+ docs = getattr(result, "documents", None) or []
74
+ if docs:
75
+ console.print("[bold]sources[/bold]")
76
+ for i, doc in enumerate(docs, 1):
77
+ title = getattr(doc, "title", None) or getattr(doc, "resource_id", "") or "?"
78
+ src = getattr(doc, "source", "")
79
+ console.print(f" {i}. [cyan]{src}[/cyan] {title}")
@@ -0,0 +1,43 @@
1
+ """`brain auth` — inspect the resolved credential and verify it against the API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import typer
8
+
9
+ from .. import client as client_mod
10
+ from .. import context, output
11
+
12
+ app = typer.Typer(help="Authentication status.", no_args_is_help=True)
13
+
14
+
15
+ def _redact(cred: str | None) -> str | None:
16
+ if not cred:
17
+ return None
18
+ return f"{cred[:6]}…{cred[-4:]}" if len(cred) > 12 else "set"
19
+
20
+
21
+ @app.command()
22
+ def status(
23
+ ctx: typer.Context,
24
+ check: bool = typer.Option(
25
+ True, "--check/--no-check", help="Verify the credential against the API (/auth/me)."
26
+ ),
27
+ fmt: Optional[output.Format] = output.FORMAT_OPTION,
28
+ ) -> None:
29
+ """Show where the credential came from and (by default) whether it works."""
30
+ app_ctx = context.get(ctx)
31
+ r = app_ctx.resolved
32
+ result: dict[str, object] = {
33
+ "authenticated": bool(r.credential),
34
+ "credential": _redact(r.credential),
35
+ "credential_source": r.source,
36
+ "base_url": r.base_url,
37
+ "user_id": r.user_id,
38
+ }
39
+ if check and r.credential:
40
+ cli = client_mod.build(r)
41
+ with client_mod.api_errors():
42
+ result["identity"] = client_mod.raw(cli, "GET", "/auth/me")
43
+ output.emit(result, output.pick(ctx, fmt))
@@ -0,0 +1,147 @@
1
+ """`brain generate` and friends — the synthesized three-tier company brain.
2
+
3
+ These hit endpoints the SDK doesn't model yet, so they go through the raw escape
4
+ hatch. ``generate`` kicks off an async Temporal workflow and (by default) polls
5
+ progress to completion, streaming phase updates to stderr so stdout stays a
6
+ clean channel for the final tree JSON.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+ import time
13
+ from typing import Any, Optional
14
+
15
+ import typer
16
+
17
+ from .. import client as client_mod
18
+ from .. import context, output
19
+
20
+ app = typer.Typer(
21
+ help="Generate and fetch the synthesized company-brain tree.", no_args_is_help=True
22
+ )
23
+
24
+ TERMINAL = {"completed", "published", "failed"}
25
+ POLL_INTERVAL_S = 3.0
26
+ DEFAULT_TIMEOUT_S = 900 # 15 min — matches the documented worst-case synthesis time
27
+
28
+
29
+ @app.command()
30
+ def generate(
31
+ ctx: typer.Context,
32
+ source: Optional[list[str]] = typer.Option(
33
+ None, "--source", "-s", help="Sources to include (repeatable). Default: all synced data."
34
+ ),
35
+ workstream: Optional[str] = typer.Option(
36
+ None, "--workstream", help="Generate for this workstream only (skip auto-detection)."
37
+ ),
38
+ user_id: Optional[str] = typer.Option(
39
+ None, "--user-id", help="Scope the personal tier to this user's data."
40
+ ),
41
+ wait: bool = typer.Option(
42
+ True,
43
+ "--wait/--no-wait",
44
+ help="Poll to completion (default) or return the tree_id and exit.",
45
+ ),
46
+ timeout: int = typer.Option(DEFAULT_TIMEOUT_S, "--timeout", help="Max seconds to wait."),
47
+ fmt: Optional[output.Format] = output.FORMAT_OPTION,
48
+ ) -> None:
49
+ """Kick off a company-brain tree generation."""
50
+ app_ctx = context.get(ctx)
51
+ cli = client_mod.build(app_ctx.resolved)
52
+ resolved_fmt = output.pick(ctx, fmt)
53
+
54
+ body: dict[str, Any] = {}
55
+ if source:
56
+ body["sources"] = source
57
+ if workstream:
58
+ body["workstream_name"] = workstream
59
+ if user_id:
60
+ body["user_id"] = user_id
61
+
62
+ with client_mod.api_errors():
63
+ started = client_mod.raw(cli, "POST", "/context-documents/tree", body=body)
64
+ tree_id = _field(started, "tree_id")
65
+ if not wait or not tree_id:
66
+ output.emit(started, resolved_fmt)
67
+ return
68
+
69
+ final = _poll(cli, tree_id, timeout)
70
+ status = (_field(final, "status") or "").lower()
71
+ if status == "failed":
72
+ raise output.fail(f"Tree {tree_id} generation failed.", output.Exit.API)
73
+ # Emit the finished tree itself, not just the progress record.
74
+ with client_mod.api_errors():
75
+ tree = client_mod.raw(cli, "GET", f"/context-documents/tree/by-id/{tree_id}")
76
+ output.emit(tree, resolved_fmt)
77
+
78
+
79
+ @app.command()
80
+ def latest(
81
+ ctx: typer.Context,
82
+ fmt: Optional[output.Format] = output.FORMAT_OPTION,
83
+ ) -> None:
84
+ """Fetch the most recent completed/published tree."""
85
+ app_ctx = context.get(ctx)
86
+ cli = client_mod.build(app_ctx.resolved)
87
+ with client_mod.api_errors():
88
+ result = client_mod.raw(cli, "GET", "/context-documents/tree/latest")
89
+ output.emit(result, output.pick(ctx, fmt))
90
+
91
+
92
+ @app.command()
93
+ def get(
94
+ ctx: typer.Context,
95
+ tree_id: str = typer.Argument(..., help="The tree ID."),
96
+ fmt: Optional[output.Format] = output.FORMAT_OPTION,
97
+ ) -> None:
98
+ """Fetch a tree by ID."""
99
+ app_ctx = context.get(ctx)
100
+ cli = client_mod.build(app_ctx.resolved)
101
+ with client_mod.api_errors():
102
+ result = client_mod.raw(cli, "GET", f"/context-documents/tree/by-id/{tree_id}")
103
+ output.emit(result, output.pick(ctx, fmt))
104
+
105
+
106
+ @app.command()
107
+ def progress(
108
+ ctx: typer.Context,
109
+ tree_id: str = typer.Argument(..., help="The tree ID."),
110
+ fmt: Optional[output.Format] = output.FORMAT_OPTION,
111
+ ) -> None:
112
+ """Check generation progress for a tree (single snapshot)."""
113
+ app_ctx = context.get(ctx)
114
+ cli = client_mod.build(app_ctx.resolved)
115
+ with client_mod.api_errors():
116
+ result = client_mod.raw(cli, "GET", f"/context-documents/tree/{tree_id}/progress")
117
+ output.emit(result, output.pick(ctx, fmt))
118
+
119
+
120
+ def _poll(cli: Any, tree_id: str, timeout: int) -> Any:
121
+ """Poll progress until terminal or timeout, narrating phase changes to stderr."""
122
+ deadline = time.monotonic() + timeout
123
+ last = None
124
+ while True:
125
+ with client_mod.api_errors():
126
+ snap = client_mod.raw(cli, "GET", f"/context-documents/tree/{tree_id}/progress")
127
+ status = (_field(snap, "status") or "").lower()
128
+ phase = _field(snap, "phase")
129
+ done, total = _field(snap, "completed_docs"), _field(snap, "total_docs")
130
+ line = f"[{status}] {phase or '...'}" + (f" {done}/{total}" if total else "")
131
+ if line != last:
132
+ print(line, file=sys.stderr, flush=True)
133
+ last = line
134
+ if status in TERMINAL:
135
+ return snap
136
+ if time.monotonic() >= deadline:
137
+ raise output.fail(
138
+ f"Timed out after {timeout}s waiting for tree {tree_id} (last status: {status}).",
139
+ output.Exit.API,
140
+ )
141
+ time.sleep(POLL_INTERVAL_S)
142
+
143
+
144
+ def _field(obj: Any, key: str) -> Any:
145
+ if isinstance(obj, dict):
146
+ return obj.get(key)
147
+ return getattr(obj, key, None)