miniouto 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.
Files changed (42) hide show
  1. miniouto/__init__.py +3 -0
  2. miniouto/cli/__init__.py +70 -0
  3. miniouto/cli/chat.py +77 -0
  4. miniouto/cli/provider.py +272 -0
  5. miniouto/cli/skill.py +51 -0
  6. miniouto/cli/style.py +112 -0
  7. miniouto/cli/tui.py +1257 -0
  8. miniouto/core/__init__.py +13 -0
  9. miniouto/core/chat.py +323 -0
  10. miniouto/core/context.py +184 -0
  11. miniouto/core/events.py +124 -0
  12. miniouto/core/lma.py +139 -0
  13. miniouto/core/providers.py +134 -0
  14. miniouto/core/runtime.py +398 -0
  15. miniouto/default_style/claude.md +333 -0
  16. miniouto/default_style/codebuff.md +244 -0
  17. miniouto/default_style/codex.md +376 -0
  18. miniouto/default_style/default.md +111 -0
  19. miniouto/default_style/oh-my-opencode.md +306 -0
  20. miniouto/default_style/opencode.md +255 -0
  21. miniouto/paths_runtime.py +8 -0
  22. miniouto/storage/__init__.py +3 -0
  23. miniouto/storage/paths.py +37 -0
  24. miniouto/storage/providers.py +70 -0
  25. miniouto/storage/sessions.py +88 -0
  26. miniouto/storage/settings.py +46 -0
  27. miniouto/storage/skills.py +98 -0
  28. miniouto/storage/styles.py +242 -0
  29. miniouto/storage/toml_io.py +23 -0
  30. miniouto/tools/__init__.py +12 -0
  31. miniouto/tools/_normalize.py +79 -0
  32. miniouto/tools/bash.py +82 -0
  33. miniouto/tools/delete.py +29 -0
  34. miniouto/tools/edit.py +221 -0
  35. miniouto/tools/media.py +140 -0
  36. miniouto/tools/registry.py +279 -0
  37. miniouto/tools/write.py +90 -0
  38. miniouto-0.1.0.dist-info/METADATA +125 -0
  39. miniouto-0.1.0.dist-info/RECORD +42 -0
  40. miniouto-0.1.0.dist-info/WHEEL +4 -0
  41. miniouto-0.1.0.dist-info/entry_points.txt +2 -0
  42. miniouto-0.1.0.dist-info/licenses/LICENSE +201 -0
miniouto/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """miniouto — a minimal, file-driven CLI agent harness built on coreouto."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,70 @@
1
+ """miniouto CLI entrypoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+ from rich.console import Console
7
+
8
+ from ..storage import paths
9
+ from . import provider as provider_module
10
+ from . import skill as skill_module
11
+ from . import style as style_module
12
+ from . import tui as tui_module
13
+ from .chat import chat_cmd
14
+
15
+ app = typer.Typer(
16
+ name="miniouto",
17
+ help="A minimal, file-driven CLI agent harness built on coreouto.",
18
+ no_args_is_help=False,
19
+ invoke_without_command=True,
20
+ add_completion=False,
21
+ )
22
+ app.add_typer(provider_module.app, name="provider")
23
+ app.add_typer(style_module.app, name="style")
24
+ app.add_typer(skill_module.app, name="skill")
25
+ app.command("chat", help="Run a single chat turn.")(chat_cmd)
26
+
27
+ console = Console()
28
+
29
+
30
+ @app.callback()
31
+ def _root(
32
+ ctx: typer.Context,
33
+ version: bool = typer.Option(False, "--version", help="Show version and exit."),
34
+ ) -> None:
35
+ paths.ensure_dirs()
36
+ if version:
37
+ from .. import __version__
38
+
39
+ console.print(f"miniouto {__version__}")
40
+ raise typer.Exit()
41
+ if ctx.invoked_subcommand is None:
42
+ tui_module.run_tui()
43
+
44
+
45
+ @app.command("status")
46
+ def status() -> None:
47
+ """Show current configuration."""
48
+
49
+ from ..storage import providers as provider_store
50
+ from ..storage import sessions as session_store
51
+ from ..storage import settings as settings_store
52
+ from ..storage import skills as skill_store
53
+ from ..storage import styles as style_store
54
+
55
+ s = settings_store.load()
56
+ active_provider = provider_store.get(s.provider) if s.provider else None
57
+ default_model = active_provider.default_model if active_provider else ""
58
+ console.print(f"[bold]Default provider:[/bold] {s.provider or '-'}")
59
+ console.print(f"[bold]Default model:[/bold] {default_model or '- (use chat --model)'}")
60
+ console.print(f"[bold]Active style:[/bold] {s.style or '-'}")
61
+ console.print(f"[bold]Session:[/bold] {s.session or '-'}")
62
+ console.print(f"[bold]Storage:[/bold] {paths.ROOT}")
63
+ console.print(f"[bold]Providers:[/bold] {', '.join(provider_store.load_all()) or '-'}")
64
+ console.print(f"[bold]Styles:[/bold] {', '.join(style_store.list_styles()) or '-'}")
65
+ console.print(f"[bold]Skills:[/bold] {', '.join(s.name for s in skill_store.list_skills()) or '-'}")
66
+ console.print(f"[bold]Sessions:[/bold] {', '.join(session_store.list_sessions()) or '-'}")
67
+
68
+
69
+ if __name__ == "__main__":
70
+ app()
miniouto/cli/chat.py ADDED
@@ -0,0 +1,77 @@
1
+ """Single-shot chat command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime
6
+ import sys
7
+ import uuid
8
+
9
+ import typer
10
+
11
+ from ..core.chat import ChatOptions, run_chat
12
+ from ..core.events import ConsoleEventSink
13
+ from ..storage import settings as settings_store
14
+
15
+
16
+ def chat_cmd(
17
+ prompt: str = typer.Argument(..., help="Prompt to send to the agent."),
18
+ name: str | None = typer.Option(
19
+ None, "--name", help="Session name. Without --name and --continue, a fresh session is generated each call."
20
+ ),
21
+ provider: str | None = typer.Option(None, "--provider", help="Override the active provider."),
22
+ model: str | None = typer.Option(None, "--model", help="Override the default model."),
23
+ style: str | None = typer.Option(None, "--style", help="Override the active style."),
24
+ max_tokens: int | None = typer.Option(None, "--max-tokens", help="Cap output tokens."),
25
+ temperature: float | None = typer.Option(None, "--temperature", help="Sampling temperature."),
26
+ continue_session: bool = typer.Option(
27
+ False, "--continue", "-c", help="Prepend the session's previous history."
28
+ ),
29
+ answer_only: bool = typer.Option(
30
+ False, "--answer-only", "-a",
31
+ help="Print only the final answer. Suppresses the session marker, loop events, and finish marker.",
32
+ ),
33
+ with_session: bool = typer.Option(
34
+ False, "--with-session",
35
+ help="Print only the session marker and the final answer. Suppresses loop events and the finish marker.",
36
+ ),
37
+ ) -> None:
38
+ """Run one prompt and print the agent's reply."""
39
+
40
+ if answer_only and with_session:
41
+ typer.secho(
42
+ "✗ --answer-only and --with-session are mutually exclusive.", err=True, fg="red"
43
+ )
44
+ raise typer.Exit(1)
45
+
46
+ if continue_session:
47
+ session_name = name or settings_store.load().session or "default"
48
+ elif name is not None:
49
+ session_name = name
50
+ else:
51
+ # Fresh session each call: timestamp + short UUID keeps names
52
+ # unique even within the same second and sortable by recency,
53
+ # so the previous chat's `settings.session` doesn't bleed in.
54
+ ts = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
55
+ suffix = uuid.uuid4().hex[:6]
56
+ session_name = f"chat-{ts}-{suffix}"
57
+ settings_store.update(session=session_name)
58
+
59
+ opts = ChatOptions(
60
+ prompt=prompt,
61
+ session=session_name,
62
+ provider=provider,
63
+ model=model,
64
+ style=style,
65
+ max_tokens=max_tokens,
66
+ temperature=temperature,
67
+ continue_session=continue_session,
68
+ )
69
+ # The sink handles all output: braille spinner + loop events share
70
+ # stdout (Rich's Live display owns one channel and keeps them
71
+ # vertically separated). The session marker is printed up front so
72
+ # callers know which session the output belongs to.
73
+ quiet = answer_only or with_session
74
+ if not answer_only:
75
+ sys.stdout.write(f"------{session_name}------\n")
76
+ sys.stdout.flush()
77
+ run_chat(opts, sink=ConsoleEventSink(quiet=quiet))
@@ -0,0 +1,272 @@
1
+ """Provider subcommands.
2
+
3
+ Three groups:
4
+
5
+ * **catalog** browse / add (sourced from the upstream lma catalog at
6
+ https://lma.blp.sh, but exposed to users as the "catalog"): `providers`,
7
+ `models`, `add`.
8
+ * **storage** ops on already-configured providers: `list`, `remove`, `default`.
9
+ * **custom** manual config (no catalog lookup): `provider custom add`.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import typer
15
+ from rich.console import Console
16
+ from rich.table import Table
17
+
18
+ from ..core import lma as catalog_api
19
+ from ..core.providers import SUPPORTED_FORMATS, add_provider_from_lma, sdk_to_format
20
+ from ..storage import paths
21
+ from ..storage import providers as provider_store
22
+ from ..storage import settings as settings_store
23
+
24
+ app = typer.Typer(help="Manage LLM providers (catalog browse + custom config).")
25
+ custom_app = typer.Typer(help="Manually configure a custom provider (advanced).")
26
+ app.add_typer(custom_app, name="custom")
27
+
28
+ console = Console()
29
+
30
+ FORMAT_HELP = (
31
+ "API compatibility: "
32
+ "openai (OpenAI Chat Completions), "
33
+ "openai-response (OpenAI Responses API), "
34
+ "anthropic (Anthropic Messages), "
35
+ "google (Google Generative AI / Gemini)."
36
+ )
37
+
38
+
39
+ # ─── catalog commands ────────────────────────────────────────────────────────
40
+
41
+
42
+ @app.command("providers")
43
+ def providers_cmd() -> None:
44
+ """List every provider available in the catalog."""
45
+
46
+ try:
47
+ providers = catalog_api.list_providers()
48
+ except Exception as exc:
49
+ console.print(f"[red]✗[/red] Failed to reach catalog: {exc}")
50
+ raise typer.Exit(code=1) from exc
51
+ if not providers:
52
+ console.print("[yellow]No providers returned by the catalog.[/yellow]")
53
+ return
54
+
55
+ table = Table(
56
+ title=f"Catalog providers ({len(providers)})",
57
+ show_header=True,
58
+ header_style="bold",
59
+ )
60
+ table.add_column("Name", style="cyan")
61
+ table.add_column("SDK")
62
+ table.add_column("API URL")
63
+ table.add_column("miniouto format", style="magenta")
64
+ table.add_column("Addable?", justify="center")
65
+ for p in providers:
66
+ name = p.get("name", "?")
67
+ sdk = p.get("sdk") or "-"
68
+ api = p.get("api") or "-"
69
+ fmt, _ = sdk_to_format(sdk, api)
70
+ fmt_str = fmt or "[red]unsupported[/red]"
71
+ addable = "[green]✓[/green]" if fmt else "[dim]✗[/dim]"
72
+ table.add_row(name, sdk, api, fmt_str, addable)
73
+ console.print(table)
74
+
75
+
76
+ @app.command("models")
77
+ def models_cmd(
78
+ provider_name: str = typer.Argument(
79
+ ..., help="Provider name (fuzzy match, e.g. 'anthropic', 'open ai')."
80
+ ),
81
+ ) -> None:
82
+ """List every model the catalog knows for a provider."""
83
+
84
+ try:
85
+ models = catalog_api.list_models(provider_name)
86
+ except Exception as exc:
87
+ console.print(f"[red]✗[/red] Failed to reach catalog: {exc}")
88
+ raise typer.Exit(code=1) from exc
89
+ if not models:
90
+ console.print(f"[yellow]No models returned for {provider_name!r}.[/yellow]")
91
+ raise typer.Exit(code=1)
92
+ table = Table(
93
+ title=f"Catalog models for {provider_name!r} ({len(models)})",
94
+ show_header=True,
95
+ header_style="bold",
96
+ )
97
+ table.add_column("ID", style="cyan")
98
+ table.add_column("Name")
99
+ for m in models:
100
+ table.add_row(m.get("id", "?"), m.get("name", "?"))
101
+ console.print(table)
102
+
103
+
104
+ @app.command("add")
105
+ def add_cmd(
106
+ provider_name: str = typer.Argument(
107
+ ..., help="Catalog provider name (e.g. 'OpenAI', 'Anthropic', 'GitHub Copilot')."
108
+ ),
109
+ api_key: str = typer.Option(..., "--api-key", help="API key for the provider."),
110
+ default_model: str = typer.Option(
111
+ "",
112
+ "--default-model",
113
+ help="Default model id. If empty, the first model the catalog lists for this provider is used.",
114
+ ),
115
+ ) -> None:
116
+ """Add a provider from the catalog by name + API key.
117
+
118
+ For manual configuration (custom base URL / format), use
119
+ `miniouto provider custom add` instead.
120
+ """
121
+
122
+ catalog_provider = catalog_api.find_provider(provider_name)
123
+ if catalog_provider is None:
124
+ console.print(
125
+ f"[red]✗[/red] No catalog provider matched {provider_name!r}. "
126
+ "Run `miniouto provider providers` to see the catalog."
127
+ )
128
+ raise typer.Exit(code=1)
129
+
130
+ name = catalog_api.slugify(catalog_provider["name"])
131
+ sdk = catalog_provider.get("sdk")
132
+ api = catalog_provider.get("api")
133
+
134
+ try:
135
+ provider = add_provider_from_lma(
136
+ name=name,
137
+ api_key=api_key,
138
+ sdk=sdk,
139
+ api=api,
140
+ default_model=default_model,
141
+ )
142
+ except ValueError as exc:
143
+ console.print(f"[red]✗[/red] {exc}")
144
+ raise typer.Exit(code=1) from exc
145
+
146
+ if not provider.default_model:
147
+ try:
148
+ models = catalog_api.list_models(catalog_provider["name"])
149
+ if models:
150
+ provider = add_provider_from_lma(
151
+ name=name,
152
+ api_key=api_key,
153
+ sdk=sdk,
154
+ api=api,
155
+ default_model=models[0].get("id", ""),
156
+ )
157
+ except Exception:
158
+ pass
159
+
160
+ paths.ensure_dirs()
161
+ if provider_store.get(name) is not None:
162
+ console.print(
163
+ f"[yellow]![/yellow] Provider [bold]{name}[/bold] already exists; overwriting."
164
+ )
165
+ provider_store.upsert(provider)
166
+ console.print(
167
+ f"[green]✓[/green] Added provider [bold]{name}[/bold] "
168
+ f"({provider.api_format}, default-model={provider.default_model or '-'})."
169
+ )
170
+
171
+
172
+ # ─── storage commands (unchanged behavior) ───────────────────────────────────
173
+
174
+
175
+ @app.command("list")
176
+ def list_cmd() -> None:
177
+ """List configured providers."""
178
+
179
+ rows = provider_store.load_all()
180
+ if not rows:
181
+ console.print(
182
+ "[yellow]No providers configured.[/yellow] "
183
+ "Run `miniouto provider add <name>` or `miniouto provider custom add`."
184
+ )
185
+ return
186
+ current = settings_store.load().provider
187
+ table = Table(title="Providers", show_header=True, header_style="bold")
188
+ table.add_column("Name", style="cyan")
189
+ table.add_column("Type")
190
+ table.add_column("Format")
191
+ table.add_column("Base URL")
192
+ table.add_column("Default Model")
193
+ table.add_column("Default", justify="center")
194
+ for p in rows.values():
195
+ marker = "[green]●[/green]" if p.name == current else ""
196
+ kind = "custom" if p.source == "custom" else "catalog"
197
+ table.add_row(
198
+ p.name,
199
+ kind,
200
+ p.api_format,
201
+ p.base_url or "-",
202
+ p.default_model or "-",
203
+ marker,
204
+ )
205
+ console.print(table)
206
+
207
+
208
+ @app.command("remove")
209
+ def remove(name: str) -> None:
210
+ """Remove a provider by name."""
211
+
212
+ if provider_store.remove(name):
213
+ console.print(f"[green]✓[/green] Removed provider [bold]{name}[/bold].")
214
+ else:
215
+ console.print(f"[red]✗[/red] Provider [bold]{name}[/bold] does not exist.")
216
+ raise typer.Exit(code=1)
217
+
218
+
219
+ @app.command("default")
220
+ def default(name: str) -> None:
221
+ """Set the default provider for chat."""
222
+
223
+ if provider_store.get(name) is None:
224
+ console.print(f"[red]✗[/red] Provider [bold]{name}[/bold] is not configured.")
225
+ raise typer.Exit(code=1)
226
+ settings_store.update(provider=name)
227
+ console.print(f"[green]✓[/green] Default provider is now [bold]{name}[/bold].")
228
+
229
+
230
+ # ─── custom subcommands ──────────────────────────────────────────────────────
231
+
232
+
233
+ @custom_app.command("add")
234
+ def add_custom(
235
+ name: str = typer.Option(..., "--name", help="Provider identifier (e.g. openai, minimax)."),
236
+ api_format: str = typer.Option("openai", "--format", help=FORMAT_HELP),
237
+ base_url: str = typer.Option(
238
+ "", "--base-url",
239
+ help="Base URL of the endpoint. For OpenAI-compatible services "
240
+ "(LocalAI, vLLM, MiniMax, Zhipu, Moonshot, etc.) use the OpenAI or "
241
+ "openai-response format with the provider's base URL. Anthropic "
242
+ "format works with the Anthropic SDK and any compatible proxy. "
243
+ "Google format accepts an api_endpoint via client_options.",
244
+ ),
245
+ api_key: str = typer.Option("", "--api-key", help="API key (omit to read from env at call time)."),
246
+ default_model: str = typer.Option(
247
+ "", "--default-model", help="Default model used when chat --model is not given."
248
+ ),
249
+ ) -> None:
250
+ """Manually add or update a custom provider.
251
+
252
+ For catalog providers (OpenAI, Anthropic, etc.), use
253
+ `miniouto provider add <name>` instead — it auto-fills base URL, format,
254
+ and default model.
255
+ """
256
+
257
+ if api_format not in SUPPORTED_FORMATS:
258
+ console.print(
259
+ f"[red]✗[/red] Unknown format {api_format!r}. "
260
+ f"Supported: {', '.join(SUPPORTED_FORMATS)}."
261
+ )
262
+ raise typer.Exit(code=1)
263
+ paths.ensure_dirs()
264
+ provider = provider_store.Provider(
265
+ name=name,
266
+ api_format=api_format,
267
+ base_url=base_url,
268
+ api_key=api_key,
269
+ default_model=default_model,
270
+ )
271
+ provider_store.upsert(provider)
272
+ console.print(f"[green]✓[/green] Saved custom provider [bold]{name}[/bold] ({api_format}).")
miniouto/cli/skill.py ADDED
@@ -0,0 +1,51 @@
1
+ """Skill CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+
9
+ from ..storage import skills as skill_store
10
+
11
+ app = typer.Typer(help="Manage agent skills.")
12
+ console = Console()
13
+
14
+
15
+ @app.command("list")
16
+ def list_cmd() -> None:
17
+ """List all available skills."""
18
+
19
+ skills = skill_store.list_skills()
20
+ if not skills:
21
+ console.print("[yellow]No skills found.[/yellow] Check ~/.agents/skills/")
22
+ return
23
+
24
+ table = Table(title="Available Skills", show_header=True, header_style="bold")
25
+ table.add_column("Name", style="cyan")
26
+ table.add_column("Description")
27
+
28
+ for skill in skills:
29
+ desc = skill.description[:80] + "..." if len(skill.description) > 80 else skill.description
30
+ table.add_row(skill.name, desc)
31
+
32
+ console.print(table)
33
+
34
+
35
+ @app.command("show")
36
+ def show(name: str) -> None:
37
+ """Show skill content."""
38
+
39
+ skill = skill_store.get_skill(name)
40
+ if skill is None:
41
+ console.print(f"[red]✗[/red] Skill [bold]{name}[/bold] not found.")
42
+ raise typer.Exit(code=1)
43
+
44
+ console.print(f"[bold]Name:[/bold] {skill.name}")
45
+ console.print(f"[bold]Description:[/bold] {skill.description}")
46
+ if skill.license:
47
+ console.print(f"[bold]License:[/bold] {skill.license}")
48
+ if skill.allowed_tools:
49
+ console.print(f"[bold]Allowed Tools:[/bold] {skill.allowed_tools}")
50
+ console.print()
51
+ console.print(skill.content)
miniouto/cli/style.py ADDED
@@ -0,0 +1,112 @@
1
+ """Style subcommands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+ from rich.console import Console
7
+
8
+ from ..storage import paths
9
+ from ..storage import settings as settings_store
10
+ from ..storage import styles as style_store
11
+
12
+ app = typer.Typer(help="Manage agent style documents.")
13
+ console = Console()
14
+
15
+
16
+ @app.command("list")
17
+ def list_cmd() -> None:
18
+ """List installed style documents."""
19
+
20
+ names = style_store.list_styles()
21
+ if not names:
22
+ console.print("[yellow]No styles installed.[/yellow]")
23
+ return
24
+ current = settings_store.load().style
25
+ for name in names:
26
+ marker = " [green]●[/green]" if name == current else ""
27
+ console.print(f" - {name}{marker}")
28
+
29
+
30
+ @app.command("set")
31
+ def set_cmd(name: str) -> None:
32
+ """Activate an installed style as the default."""
33
+
34
+ if style_store.read(name) is None:
35
+ console.print(f"[red]✗[/red] Style [bold]{name}[/bold] is not installed.")
36
+ raise typer.Exit(code=1)
37
+ settings_store.update(style=name)
38
+ console.print(f"[green]✓[/green] Active style is now [bold]{name}[/bold].")
39
+
40
+
41
+ @app.command("add")
42
+ def add(
43
+ repo_url: str = typer.Argument(..., help="Git host URL whose /style-md/ directory defines styles."),
44
+ name: str | None = typer.Option(
45
+ None,
46
+ "--name",
47
+ help="Override style name (otherwise the file's basename is used).",
48
+ ),
49
+ ) -> None:
50
+ """Add styles by fetching /style-md/ from a remote repository."""
51
+
52
+ paths.ensure_dirs()
53
+ try:
54
+ added = style_store.add_from_repo(repo_url, name_override=name)
55
+ except Exception as exc:
56
+ console.print(f"[red]✗[/red] Failed to fetch styles: {exc}")
57
+ raise typer.Exit(code=1) from exc
58
+ console.print(f"[green]✓[/green] Added/updated styles: {', '.join(added)}")
59
+
60
+
61
+ @app.command("update")
62
+ def update_cmd() -> None:
63
+ """Refresh every style to its latest source.
64
+
65
+ Re-seeds all bundled styles from the miniouto package, then re-fetches
66
+ every repo previously added via ``style add``. Same-name files are
67
+ overwritten in place. Styles you created by hand (no matching bundled
68
+ template and no recorded repo) are left untouched.
69
+ """
70
+
71
+ paths.ensure_dirs()
72
+ refreshed_bundled = style_store.bundled_style_names()
73
+
74
+ repos = style_store.list_repos()
75
+ failed: list[tuple[str, str]] = []
76
+ refreshed_repos: list[str] = []
77
+ for url in repos:
78
+ try:
79
+ added = style_store.add_from_repo(url)
80
+ except Exception as exc:
81
+ failed.append((url, str(exc)))
82
+ continue
83
+ refreshed_repos.extend(added)
84
+
85
+ console.print(
86
+ f"[green]✓[/green] Refreshed bundled styles: "
87
+ f"{', '.join(refreshed_bundled) or '-'}"
88
+ )
89
+ if repos:
90
+ ok = sorted(set(refreshed_repos))
91
+ console.print(
92
+ f"[green]✓[/green] Re-fetched {len(repos)} repo(s): "
93
+ f"{', '.join(ok) or 'no .md files'}"
94
+ )
95
+ for url, err in failed:
96
+ console.print(f"[red]✗[/red] Failed to update {url}: {err}")
97
+ elif not repos:
98
+ console.print(
99
+ "[dim]No repo styles to update. Use `style add <repo-url>` "
100
+ "to track a repo.[/dim]"
101
+ )
102
+
103
+
104
+ @app.command("show")
105
+ def show(name: str) -> None:
106
+ """Print the contents of a style document."""
107
+
108
+ content = style_store.read(name)
109
+ if content is None:
110
+ console.print(f"[red]✗[/red] Style [bold]{name}[/bold] is not installed.")
111
+ raise typer.Exit(code=1)
112
+ console.print(content)