From f918541c6952c0baf6e6b4292b0cacd57780960a Mon Sep 17 00:00:00 2001 From: Jeremy Bedient Date: Tue, 1 Sep 2026 16:48:13 -0400 Subject: [PATCH] Show the Kizen logo mark before init's first prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Interactive terminals only — piped output and test runners leave console.is_terminal False, and --help never reaches the command body. Degrades to a compact box, then to the tagline alone, when the real terminal is too small to hold the art without wrapping into garbage. console.size cannot answer that question: the shared console fixes its width at 220 so tables render consistently, so it always reports 220 here rather than the terminal's actual width. shutil.get_terminal_size is the same real signal Rich falls back to internally. --- CHANGELOG.md | 6 ++ src/kizen_builder/cli/init.py | 117 ++++++++++++++++++++++++++++++++++ tests/test_cli.py | 97 ++++++++++++++++++++++++++++ 3 files changed, 220 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2051e12..b5b4c87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,12 @@ called out explicitly under **Changed** or **Removed**. is omitted for custom-object namespaces — there's no live object lookup to answer it offline, the same class of preview-vs-real divergence `craft-config` already documents for Image blocks. +- **`kizen init` shows the Kizen logo banner before the setup prompts**, when + run in a real terminal wide and tall enough for it (a compact wordmark or + a plain tagline show instead on a smaller one). Piped input, `--help`, and + other non-interactive invocations print nothing new — the banner is gated + on the same terminal-detection signal the rest of the CLI already relies + on. - **`kizen messages templates get/clone/update/delete` — the email-template surface can now be read and written, not just listed.** `list` only ever diff --git a/src/kizen_builder/cli/init.py b/src/kizen_builder/cli/init.py index 0f44043..6801100 100644 --- a/src/kizen_builder/cli/init.py +++ b/src/kizen_builder/cli/init.py @@ -5,10 +5,12 @@ from __future__ import annotations import re +import shutil from pathlib import Path import typer from rich.prompt import Prompt +from rich.text import Text from kizen_builder import docs as docs_res from kizen_builder import profiles @@ -16,6 +18,120 @@ from kizen_builder.cli._shared import app, console, err_console from kizen_builder.config import EnvConfig +# The dotted-halftone Kizen logo mark from Kizen's node CLI (@kizenapps/cli's +# src/ui/Logo.tsx), transcribed verbatim from that package's published source +# map so the two CLIs show the same mark. 22 rows of exactly 43 columns — +# trailing spaces are part of the art, so don't let an editor strip them +# (`ruff format` leaves string-literal contents alone). +_BANNER_ART_LINES: tuple[str, ...] = ( + " :::::::: ", + " :::::: ::::::::::: ", + " :::::: :::::::::: ", + " :::::: :::::: ::::::: ", + " ::::: :::::: ::::::::::::::::: ", + " ::::: ::::: ::::::::::::::::::: ", + " ::::: ::::: :::::: ::::: ", + ":::: ::::: ::: ::::: :: ", + "::::: :::::: :::::::::: ", + "::::: :::::: ::::::::::: ", + " ::::: ::: ::::::: ", + " ::::::: ::: ::::: ", + " ::::::::::: :::::: :::::", + " :::::::::: :::::: :::::", + " :: ::::: ::: ::::: ::::", + " ::::: :::::: ::::: ::::: ::: ", + " ::::::::::::::::::: ::::: ::::: ", + " ::::::::::::::::: ::::: ::::: ", + " ::::::::: :::::: :::::: ", + " :::::::::: :::::: ", + " ::::::::::: :::::: ", + " :::::::: ", +) +_BANNER_ART_WIDTH = 43 +_BANNER_ART_HEIGHT = 22 +# Matches the node CLI's own rendering (ink's ) — the +# terminal's named cyan, not a truecolor hex. +_BANNER_ACCENT = "cyan" + +# Compact fallback for a terminal too narrow/short for the full art — the +# node CLI has no equivalent; this tier is our own addition. +_COMPACT_BANNER_INNER_WIDTH = 18 +_COMPACT_BANNER_LINES = ( + f"╔{'═' * _COMPACT_BANNER_INNER_WIDTH}╗", + f"║{'KIZEN'.center(_COMPACT_BANNER_INNER_WIDTH)}║", + f"╚{'═' * _COMPACT_BANNER_INNER_WIDTH}╝", +) +_COMPACT_BANNER_WIDTH = _COMPACT_BANNER_INNER_WIDTH + 2 + +# Named for this tool rather than reusing the node CLI's tagline ("Kizen App +# Development Toolkit"), which describes that tool. +_BANNER_TAGLINE = "Kizen Admin CLI" + + +def _init_is_interactive() -> bool: + """Whether this invocation should show interactive-only chrome (the + banner). + + A thin wrapper around `console.is_terminal` — the same signal `_ask()` + relies on via Rich's own prompt handling — kept as its own function so a + test can monkeypatch this one function instead of mutating the shared + `console` singleton's private `_force_terminal` attribute. The singleton + is process-wide, so poking at it directly would leak between tests. + """ + return console.is_terminal + + +def _print_banner() -> None: + """A short banner before the first prompt: the Kizen logo mark above a + tagline, real terminals only. + + Never runs for piped output or under `CliRunner` (both leave + `console.is_terminal` `False`), and never appears in `--help` output + (Typer/Click don't call the command body for `--help`). + + The art is skipped in favor of a compact fallback, or dropped entirely + for just the tagline, when the real terminal is too small to hold it + without wrapping into garbage. `console.size` can't answer that: + `_shared.py` fixes the shared console's width at 220 (so tables render + consistently at any terminal size), which means `console.size.width` + always reports 220 here, not the terminal's actual width. + `shutil.get_terminal_size` is what Rich itself falls back to internally + for a console with no fixed width, so this reads the same real signal. + """ + if not _init_is_interactive(): + return + + term_width, term_height = shutil.get_terminal_size(fallback=(80, 24)) + + if term_width >= _BANNER_ART_WIDTH and term_height >= _BANNER_ART_HEIGHT + 3: + # Each row prints as its own plain `Text` — never parsed as Rich + # markup (a `Text` object bypasses markup parsing entirely, unlike a + # string) and never reflowed (`no_wrap` + `overflow="crop"`), so + # Rich can't refold or truncate-with-ellipsis a line on its own. + for line in _BANNER_ART_LINES: + console.print( + Text(line, style=_BANNER_ACCENT, no_wrap=True, overflow="crop") + ) + console.print() + console.print(Text(_BANNER_TAGLINE, style=f"bold {_BANNER_ACCENT}")) + console.print() + elif term_width >= _COMPACT_BANNER_WIDTH and term_height >= 5: + for line in _COMPACT_BANNER_LINES: + console.print( + Text( + line, + style=f"bold {_BANNER_ACCENT}", + no_wrap=True, + overflow="crop", + ) + ) + console.print() + console.print(Text(_BANNER_TAGLINE, style=f"bold {_BANNER_ACCENT}")) + console.print() + else: + console.print(Text(_BANNER_TAGLINE, style=f"bold {_BANNER_ACCENT}")) + console.print() + def _validate_creds(cfg: EnvConfig) -> None: """Confirm credentials work with a cheap live read before we store them.""" @@ -178,6 +294,7 @@ def init( anything still missing is prompted for, so this works interactively and headlessly from the same command. """ + _print_banner() cwd = Path.cwd() if not profile: profile = _ask("Profile name", _default_profile_name(cwd), flag="--profile") diff --git a/tests/test_cli.py b/tests/test_cli.py index aa50136..0cf8725 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -12,6 +12,7 @@ from typer.testing import CliRunner import kizen_builder.cli as cli +from kizen_builder.cli import init as init_cli from kizen_builder.cli import objects as objects_cli from kizen_builder.tools import automations as auto_tools from kizen_builder.tools import forms as form_tools @@ -2062,6 +2063,102 @@ def test_init_stores_profile_and_pins_directory(monkeypatch, tmp_path): assert data == {"profile": "alpha", "business_id": "AAAA"} +def test_init_banner_absent_when_not_interactive(monkeypatch, tmp_path): + """CliRunner's captured stream isn't a real tty, so `_init_is_interactive` + (which wraps `console.is_terminal`) is already `False` by default — no + monkeypatching needed to prove the piped/non-interactive case.""" + from kizen_builder import config, profiles + + config.set_profile_override(None) + monkeypatch.chdir(tmp_path) + + result = runner.invoke( + cli.app, + ["init", "--profile", "banner-off", "--skip-validation"], + input="apikey\nCCCC\nuser1\ngo\n", + ) + assert result.exit_code == 0, result.output + assert init_cli._BANNER_TAGLINE not in result.stdout + assert "::::" not in result.stdout + assert profiles.get_profile("banner-off") is not None + + +def test_init_banner_shows_full_art_on_a_wide_terminal(monkeypatch, tmp_path): + """Interactive, and the (measured, real) terminal is comfortably bigger + than the 43x22 art: the full art and tagline print. + + `_init_is_interactive` and `shutil.get_terminal_size` are monkeypatched + directly on the `init` module rather than mutating the shared `console` + singleton — see `_init_is_interactive`'s docstring for why.""" + from kizen_builder import config, profiles + + config.set_profile_override(None) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(init_cli, "_init_is_interactive", lambda: True) + monkeypatch.setattr( + init_cli.shutil, "get_terminal_size", lambda fallback=(80, 24): (120, 40) + ) + + result = runner.invoke( + cli.app, + ["init", "--profile", "banner-wide", "--skip-validation"], + input="apikey\nDDDD\nuser1\ngo\n", + ) + assert result.exit_code == 0, result.output + assert init_cli._BANNER_TAGLINE in result.stdout + assert "::::" in result.stdout # the halftone art rendered + assert "KIZEN" not in result.stdout # not the compact fallback + assert profiles.get_profile("banner-wide") is not None + + +def test_init_banner_degrades_to_compact_form_on_narrow_terminal(monkeypatch, tmp_path): + """Interactive, but the terminal is narrower than the 43-column art: the + bordered KIZEN wordmark prints instead, not the full halftone art.""" + from kizen_builder import config, profiles + + config.set_profile_override(None) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(init_cli, "_init_is_interactive", lambda: True) + monkeypatch.setattr( + init_cli.shutil, "get_terminal_size", lambda fallback=(80, 24): (30, 24) + ) + + result = runner.invoke( + cli.app, + ["init", "--profile", "banner-narrow", "--skip-validation"], + input="apikey\nEEEE\nuser1\ngo\n", + ) + assert result.exit_code == 0, result.output + assert init_cli._BANNER_TAGLINE in result.stdout + assert "KIZEN" in result.stdout + assert "::::" not in result.stdout + assert profiles.get_profile("banner-narrow") is not None + + +def test_init_banner_shows_tagline_only_on_tiny_terminal(monkeypatch, tmp_path): + """Interactive, but the terminal is too small for even the compact + fallback: only the plain tagline prints.""" + from kizen_builder import config, profiles + + config.set_profile_override(None) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(init_cli, "_init_is_interactive", lambda: True) + monkeypatch.setattr( + init_cli.shutil, "get_terminal_size", lambda fallback=(80, 24): (10, 3) + ) + + result = runner.invoke( + cli.app, + ["init", "--profile", "banner-tiny", "--skip-validation"], + input="apikey\nFFFF\nuser1\ngo\n", + ) + assert result.exit_code == 0, result.output + assert init_cli._BANNER_TAGLINE in result.stdout + assert "KIZEN" not in result.stdout + assert "::::" not in result.stdout + assert profiles.get_profile("banner-tiny") is not None + + def test_init_environment_picker_resolves_named_host(monkeypatch, tmp_path): from kizen_builder import config, profiles