Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
117 changes: 117 additions & 0 deletions src/kizen_builder/cli/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,133 @@
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
from kizen_builder.api.client import KizenAPIError, KizenClient
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 <Text color="cyan">) — 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."""
Expand Down Expand Up @@ -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")
Expand Down
97 changes: 97 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
Loading