diff --git a/.skillware.yaml.example b/.skillware.yaml.example index e930652..3b99472 100644 --- a/.skillware.yaml.example +++ b/.skillware.yaml.example @@ -25,6 +25,11 @@ legacy: # When true, SKILLWARE_SKILL_PATH is merged as external roots (default). honor_skillware_skill_path: true +# Optional project override for the global CLI theme. +# Supported values: pastel (default/fallback), ocean, mono. +# presentation: +# theme: ocean + # Mail operator settings for office/gmail_handler (active in Skillware 0.5+): # mail: # addressbook_path: ~/.config/skillware/addressbook.yaml @@ -48,7 +53,5 @@ legacy: # CLI: skillware mail addressbook init | skillware mail signature set # Reserved for future releases (ignored today; preserved by skillware config show): -# theme: -# preset: default # chains: # default: [] diff --git a/CHANGELOG.md b/CHANGELOG.md index 6612fe5..bd44f78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ Contributors add user-facing entries under `[Unreleased]` in the same PR. Mainta ## [Unreleased] +### Added + +- **CLI:** User-configurable `pastel`, `ocean`, and `mono` presentation themes; interactive menu selection persists globally, project config can override it, and unknown values fall back to `pastel` (#248). +- **CLI:** The mail submenu and direct mail commands now follow the active presentation theme (#248). + ## [0.5.3] - 2026-09-01 ### Added diff --git a/docs/usage/cli.md b/docs/usage/cli.md index d933563..0cf6ac4 100644 --- a/docs/usage/cli.md +++ b/docs/usage/cli.md @@ -20,8 +20,8 @@ version tagline, and footer links. The block art (from `skillware/cli.py`): ``` Tagline: `Skillware v{version} — Skill Management Framework`. Gradient colors -are listed under [Color theme](#color-theme). See [Interactive menu](#interactive-menu) -for a terminal screenshot. +follow the active [color theme](#color-themes). See +[Interactive menu](#interactive-menu) for a terminal screenshot. ## Installation @@ -120,6 +120,7 @@ Available commands: | `5` / `doctor` | Check manifest deps and skill.py import readiness | Available | | `6` / `help` | Grouped help (Skills, Examples, Paths, Config, Mail, General) with doc links | Available | | `7` / `mail` | Mail submenu — address book and signature setup for `office/gmail_handler` | Available | +| `8` / `theme` | Choose `pastel`, `ocean`, or `mono` and save it to global config | Available | ## Grouped help @@ -131,10 +132,11 @@ Available commands: | `2` / `examples` | indexed runnable scripts | | `3` / `paths` | resolution and paths submenu | | `4` / `config` | merged YAML settings | -| `5` / `general` | menu, `--help`, `--version` | -| `6` / `install` | pip install skillware | -| `7` / `docs` | link to this CLI guide | -| `8` / `interactive` | numbered splash menu | +| `5` / `mail` | address book and signature setup | +| `6` / `general` | menu, `--help`, `--version` | +| `7` / `install` | pip install skillware | +| `8` / `docs` | link to this CLI guide | +| `9` / `interactive` | numbered splash menu | Brief `--help` groups (same topics, less detail): @@ -143,7 +145,8 @@ Brief `--help` groups (same topics, less detail): | **Skills** | `list`, `test`, `doctor` | | **Examples** | `examples` | | **Paths** | `paths`, interactive paths submenu | -| **Config** | `config show` | +| **Config** | `config show`, interactive theme picker | +| **Mail** | `mail`, interactive mail submenu | | **General** | interactive menu, `--help`, `--version` | ## Commands @@ -291,7 +294,9 @@ Interactive menu: **`5` / `doctor`**. ### skillware config -Show merged global + project Skillware configuration (read-only). The `paths` and `mail` sections are active today; other top-level keys are preserved for future settings (themes, chains, etc.). +Show merged global + project Skillware configuration (read-only). The `paths`, +`mail`, and `presentation` sections are active; other top-level keys are +preserved for future settings. skillware config show @@ -313,6 +318,8 @@ resolution: - bundled legacy: honor_skillware_skill_path: true +presentation: + theme: ocean mail: addressbook_path: ~/.config/skillware/addressbook.yaml signature_path: ~/.config/skillware/mail_signature.txt @@ -325,6 +332,17 @@ mail: When no config file exists, resolution stays **legacy**: `SKILLWARE_SKILL_PATH` → `./skills/` walk → bundled. When config exists, `resolution.order` applies (default: project → external → bundled). The **bundled** registry from `pip install skillware` is always included and cannot be disabled. **Pip-only installs with no local `skills/` folder still resolve bundled registry skills** — only roots that exist on disk are searched; an empty project tier does not block bundled. +`skillware config show` reports the effective `presentation.theme`. To change +the global theme without editing YAML, run `skillware`, choose **`8` / `theme`**, +then select a built-in theme by number or name. The picker writes only +`presentation.theme` in the global config and preserves unrelated settings. + +A project `.skillware.yaml` value overrides the global selection while the CLI +runs inside that project. The picker still saves the global preference and +prints a notice that the project theme remains active. Remove or change the +project `presentation.theme` value to use the global selection there. Missing, +malformed, or unknown theme values fall back safely to `pastel`. + ### skillware mail Operator UX for **`office/gmail_handler`** address book, email signatures (including multi-profile), and attachment path settings — without editing bundled skill files. **Full operator guide:** [`docs/skills/gmail_handler.md`](../skills/gmail_handler.md) (fresh install checklist, precedence, plain vs HTML MIME, attachments, persistence). @@ -418,18 +436,28 @@ the same condition `SkillLoader` requires to load a skill successfully. `skillware list` always shows the **path-derived ID**; it does not read `manifest["name"]` for the ID column. Keep manifest `name` aligned with that ID so agent loops and `SkillLoader.to_*_tool()` stay consistent. `SkillLoader.load_skill()` warns via `SkillwareIdentityWarning` when a registry-layout skill has a missing or mismatched `name` (flat private skills under `//` are not checked). See [Agent loops](agent_loops.md#tool-name-matching). -## Color theme +## Color themes -The CLI uses a pastel color palette consistent with the project's visual identity: +The selected theme is applied to tables, headings, categories, skill IDs, +menus, links, statuses, errors, and the splash gradient. -| Element | Color | Hex | +| Theme | Description | Splash gradient | | :--- | :--- | :--- | -| Table headers and borders | Lavender | `#C7CEEA` | -| Category column | Peach | `#FFDAC1` | -| Skill ID column | Mint | `#B5EAD7` | -| Splash logo and tagline | Gradient (ice → sky → blush) | `#D4E4F1` → `#79B6D8` → `#EBD8DC` | -| Splash footer links | Lavender | `#C7CEEA` | -| Interactive menu | Peach | `#FFDAC1` | +| `pastel` | Default and fallback; preserves the original Skillware lavender, peach, mint, ice, sky, and blush palette | `#D4E4F1` → `#79B6D8` → `#EBD8DC` | +| `ocean` | Deep blue, sky blue, and cyan | `#0C4A6E` → `#0284C7` → `#7DD3FC` | +| `mono` | Grayscale presentation | `#F0F0F0` → `#A0A0A0` → `#606060` | + +Choose a theme interactively: + +```text +skillware +> 8 +theme> ocean +``` + +The selection takes effect for subsequent output in the same session and is +used on the next CLI start. The splash is printed once at startup, so restart +the CLI to see the newly selected splash gradient. ## short_description field @@ -440,4 +468,3 @@ a concise one-line summary shown in `skillware list`: If `short_description` is absent, the CLI falls back to the first sentence of `description`, truncated to 80 characters. - diff --git a/skillware/cli.py b/skillware/cli.py index 60211be..d96e9ec 100644 --- a/skillware/cli.py +++ b/skillware/cli.py @@ -27,10 +27,12 @@ load_merged_config, load_project_paths_settings, project_config_write_path, + save_global_presentation_theme, save_project_config, ) from skillware.core.mail_config import format_mail_config_lines from skillware.cli_mail import cmd_mail, cmd_mail_submenu +from skillware.cli_theme import THEMES, ThemePalette, active_theme from skillware.core.discovery import ( SKILLWARE_SKILL_PATH_ENV, bundled_skill_root, @@ -42,13 +44,45 @@ ) from skillware.version_policy import emit_upgrade_advisory, get_installed_version -TABLE_STYLE = "bold #C7CEEA" # lavender - headers -CATEGORY_STYLE = "bold #FFDAC1" # peach - category column -ID_STYLE = "#B5EAD7" # mint - skill ID column -BORDER_STYLE = "#C7CEEA" # lavender - table border -SPLASH_STYLE = "#C7CEEA" # lavender - skillware splash color -MENU_STYLE = "#FFDAC1" # peach - menu category +def _active_theme() -> ThemePalette: + """Return the configured palette; config normalization guarantees fallback.""" + return active_theme() + + +_DEFAULT_PALETTE = THEMES["pastel"] +TABLE_STYLE = _DEFAULT_PALETTE.heading_style +CATEGORY_STYLE = _DEFAULT_PALETTE.category_style +ID_STYLE = _DEFAULT_PALETTE.id_style +BORDER_STYLE = _DEFAULT_PALETTE.border_style +SPLASH_STYLE = _DEFAULT_PALETTE.splash_style +MENU_STYLE = _DEFAULT_PALETTE.menu_style +ERROR_STYLE = f"bold {_DEFAULT_PALETTE.error_color}" +ERROR_DIM_STYLE = f"dim {_DEFAULT_PALETTE.error_color}" +SPLASH_GRADIENT_START = _DEFAULT_PALETTE.gradient_start +SPLASH_GRADIENT_MID = _DEFAULT_PALETTE.gradient_mid +SPLASH_GRADIENT_END = _DEFAULT_PALETTE.gradient_end + + +def _apply_active_theme() -> None: + """Refresh module styles from the currently merged configuration.""" + palette = _active_theme() + global TABLE_STYLE, CATEGORY_STYLE, ID_STYLE, BORDER_STYLE + global SPLASH_STYLE, MENU_STYLE, ERROR_STYLE, ERROR_DIM_STYLE + global SPLASH_GRADIENT_START, SPLASH_GRADIENT_MID, SPLASH_GRADIENT_END + + TABLE_STYLE = palette.heading_style + CATEGORY_STYLE = palette.category_style + ID_STYLE = palette.id_style + BORDER_STYLE = palette.border_style + SPLASH_STYLE = palette.splash_style + MENU_STYLE = palette.menu_style + ERROR_STYLE = f"bold {palette.error_color}" + ERROR_DIM_STYLE = f"dim {palette.error_color}" + SPLASH_GRADIENT_START = palette.gradient_start + SPLASH_GRADIENT_MID = palette.gradient_mid + SPLASH_GRADIENT_END = palette.gradient_end + _DOCS_CLI = "docs/usage/cli.md" _DOCS_CLI_LIST = f"{_DOCS_CLI}#skillware-list" @@ -97,6 +131,7 @@ "Config", [ ("skillware config show", "merged global + project YAML (read-only)"), + ("skillware (menu 8 / theme)", "choose and save the global CLI theme"), ], _DOCS_CLI_CONFIG, ), @@ -137,6 +172,12 @@ ("6", "flat", "diagnose flat-layout skills not shown in list"), ] +_THEME_CHOICES = [ + ("1", "pastel", "original Skillware palette"), + ("2", "ocean", "deep blue, sky, and cyan"), + ("3", "mono", "grayscale"), +] + _NAV_EXIT = "exit" _NAV_BACK = "back" @@ -163,10 +204,6 @@ "skillware mail addressbook show", "skillware doctor --category compliance", ) -SPLASH_GRADIENT_START = (0xD4, 0xE4, 0xF1) -SPLASH_GRADIENT_MID = (0x79, 0xB6, 0xD8) -SPLASH_GRADIENT_END = (0xEB, 0xD8, 0xDC) - _SPLASH_LOGO_LINES = ( " ███████╗██╗ ██╗██╗██╗ ██╗ ██╗ ██╗ █████╗ ██████╗ ███████╗", " ██╔════╝██║ ██╔╝██║██║ ██║ ██║ ██║██╔══██╗██╔══██╗██╔════╝", @@ -215,7 +252,7 @@ def _example_github_url(script: str) -> str: def _example_github_cell(script: str) -> Text: """Compact clickable label for the examples table (full URL is the link target).""" url = _example_github_url(script) - return Text.from_markup(f'[link="{url}" dim #C7CEEA]{script}[/link]') + return Text.from_markup(f'[link="{url}" dim {SPLASH_STYLE}]{script}[/link]') def _examples_readme_path() -> Optional[Path]: @@ -447,6 +484,7 @@ def cmd_test( console=None, ) -> int: """Run bundle tests via pytest. Returns pytest's exit code.""" + _apply_active_theme() if console is None: console = Console(stderr=True) @@ -456,7 +494,7 @@ def cmd_test( category=category, ) if error: - console.print(error, style="bold #FF9AA2") + console.print(error, style=ERROR_STYLE) return 2 if skill_id and category else 1 pytest_args = [sys.executable, "-m", "pytest"] @@ -478,6 +516,7 @@ def cmd_list( console=None, ) -> None: """Print a formatted table of all available skills.""" + _apply_active_theme() if console is None: console = Console() @@ -500,7 +539,7 @@ def cmd_list( console.print( "examples/README.md not found locally or on GitHub; " "cannot show example counts.", - style="bold #FF9AA2", + style=ERROR_STYLE, ) else: example_counts = _example_counts_by_skill(rows) @@ -547,6 +586,7 @@ def cmd_examples( console=None, ) -> int: """Print runnable example scripts from examples/README.md.""" + _apply_active_theme() if console is None: console = Console() @@ -554,7 +594,7 @@ def cmd_examples( if readme_source is None: console.print( "Could not load examples/README.md from the repo or GitHub.", - style="bold #FF9AA2", + style=ERROR_STYLE, ) return 1 @@ -563,7 +603,7 @@ def cmd_examples( if len(parts) != 2 or not all(parts): console.print( f"Invalid skill ID '{skill_id}'. Expected category/skill_name.", - style="bold #FF9AA2", + style=ERROR_STYLE, ) return 2 @@ -572,7 +612,7 @@ def cmd_examples( console.print( f"No indexed examples for '{skill_id}'. " f"See {_examples_readme_display_path(readme_source)} for the full inventory.", - style="bold #FF9AA2", + style=ERROR_STYLE, ) return 1 @@ -649,7 +689,7 @@ def _print_help_command_group( console, group: Tuple[str, List[Tuple[str, str]], str] ) -> None: group_name, commands, doc_link = group - console.print(Text(group_name, style=f"bold {TABLE_STYLE}")) + console.print(Text(group_name, style=TABLE_STYLE)) for command, description in commands: console.print(f" {command} — {description}", style=MENU_STYLE) console.print(f" Read more: {doc_link}", style=f"dim {SPLASH_STYLE}") @@ -657,20 +697,20 @@ def _print_help_command_group( def _print_help_index(console) -> None: - console.print(Text("Usage", style=f"bold {TABLE_STYLE}")) + console.print(Text("Usage", style=TABLE_STYLE)) console.print( " Run skillware and choose help (6) for full topic details.", style="dim", ) console.print() - console.print(Text("Topics", style=f"bold {TABLE_STYLE}")) + console.print(Text("Topics", style=TABLE_STYLE)) for key, slug, summary, _target in _HELP_MENU: console.print(f" {key} {slug:<12}— {summary}", style=MENU_STYLE) console.print() def _print_cli_usage_examples(console) -> None: - console.print(Text("CLI usage examples", style=f"bold {TABLE_STYLE}")) + console.print(Text("CLI usage examples", style=TABLE_STYLE)) for line in _CLI_USAGE_EXAMPLES: console.print(f" {line}", style=MENU_STYLE) console.print() @@ -678,32 +718,33 @@ def _print_cli_usage_examples(console) -> None: def _print_help_static_topic(console, topic: str) -> None: if topic == "install": - console.print(Text("Install", style=f"bold {TABLE_STYLE}")) + console.print(Text("Install", style=TABLE_STYLE)) console.print(" pip install skillware", style=MENU_STYLE) console.print(' pip install -e ".[dev,all]" # local development', style="dim") elif topic == "docs": - console.print(Text("Docs", style=f"bold {TABLE_STYLE}")) + console.print(Text("Docs", style=TABLE_STYLE)) console.print( " https://github.com/arpahls/skillware/blob/main/docs/usage/cli.md", style=f"dim {SPLASH_STYLE}", ) elif topic == "interactive": - console.print(Text("Interactive mode", style=f"bold {TABLE_STYLE}")) + console.print(Text("Interactive mode", style=TABLE_STYLE)) console.print(" skillware — open splash menu", style=MENU_STYLE) - console.print(" 1-6 or command name — run a command", style="dim") + console.print(" 1-8 or command name — run a command", style="dim") console.print(" 0 — exit from any menu level", style="dim") console.print() def _print_help_groups(console, *, compact: bool = False) -> None: """Print all help groups (legacy flat dump). Prefer cmd_help(brief=True).""" - console.print(Text("Usage", style=f"bold {TABLE_STYLE}")) + console.print(Text("Usage", style=TABLE_STYLE)) for group in HELP_GROUPS: _print_help_command_group(console, group) def cmd_help_submenu(console=None, input_fn=None) -> Optional[str]: """Interactive help topics. Returns _NAV_EXIT to quit Skillware.""" + _apply_active_theme() if console is None: console = Console() @@ -711,7 +752,7 @@ def cmd_help_submenu(console=None, input_fn=None) -> Optional[str]: topic_map.update({slug: target for key, slug, _summary, target in _HELP_MENU}) while True: - console.print(Text("Help", style=f"bold {TABLE_STYLE}")) + console.print(Text("Help", style=TABLE_STYLE)) for key, slug, summary, _target in _HELP_MENU: console.print(f" [{key}] {slug:<12}— {summary}", style=MENU_STYLE) _print_nav_footer(console, show_back=True) @@ -727,7 +768,7 @@ def cmd_help_submenu(console=None, input_fn=None) -> Optional[str]: target = topic_map.get(choice.lower()) if target is None: - console.print(f" Unknown topic: '{choice}'", style="dim #FF9AA2") + console.print(f" Unknown topic: '{choice}'", style=ERROR_DIM_STYLE) console.print() continue @@ -746,7 +787,7 @@ def cmd_help_submenu(console=None, input_fn=None) -> Optional[str]: def _print_paths_submenu(console) -> None: - console.print(Text("Paths", style=f"bold {TABLE_STYLE}")) + console.print(Text("Paths", style=TABLE_STYLE)) console.print( f" Project config: {project_config_write_path()}", style="dim", @@ -763,7 +804,7 @@ def _print_paths_submenu(console) -> None: def _cmd_paths_show_bundled(console) -> None: root = bundled_skill_root(include_missing=True) - console.print(Text("Bundled registry (read-only)", style=f"bold {TABLE_STYLE}")) + console.print(Text("Bundled registry (read-only)", style=TABLE_STYLE)) console.print(f" path: {root.path}", style=MENU_STYLE) console.print(f" status: {'ok' if root.exists else 'missing'}", style="dim") if root.exists: @@ -788,13 +829,13 @@ def _cmd_paths_shadows_only( console.print("No shadowing detected across active roots.", style=MENU_STYLE) return - console.print(Text("Shadowing (first root wins)", style=f"bold {TABLE_STYLE}")) + console.print(Text("Shadowing (first root wins)", style=TABLE_STYLE)) for conflict in conflicts[:30]: console.print( f" {conflict.skill_id}: " f"{conflict.winner.tier.value} at {conflict.winner.path} " f"shadows {conflict.shadowed.tier.value} at {conflict.shadowed.path}", - style="bold #FF9AA2", + style=ERROR_STYLE, ) if len(conflicts) > 30: console.print(f" … and {len(conflicts) - 30} more", style="dim") @@ -804,9 +845,7 @@ def _cmd_paths_flat_diagnose( console, skills_root_override: Optional[Path] = None ) -> None: roots = get_skill_roots(skills_root_override, for_display=True) - console.print( - Text("Flat-layout skills (loadable, not in list)", style=f"bold {TABLE_STYLE}") - ) + console.print(Text("Flat-layout skills (loadable, not in list)", style=TABLE_STYLE)) found_any = False for root in roots: if not root.exists: @@ -851,7 +890,7 @@ def _cmd_paths_edit_project(console, input_fn=None) -> None: else: candidate = Path(raw).expanduser() if not candidate.is_dir(): - console.print(f" Not a directory: {candidate}", style="bold #FF9AA2") + console.print(f" Not a directory: {candidate}", style=ERROR_STYLE) return paths.project = str(candidate.resolve()) @@ -862,9 +901,7 @@ def _cmd_paths_edit_project(console, input_fn=None) -> None: def _cmd_paths_edit_external(console, input_fn=None) -> None: paths = load_project_paths_settings() while True: - console.print( - Text("External paths (project config)", style=f"bold {TABLE_STYLE}") - ) + console.print(Text("External paths (project config)", style=TABLE_STYLE)) if paths.external: for index, entry in enumerate(paths.external, start=1): console.print(f" [{index}] {entry}", style=MENU_STYLE) @@ -888,7 +925,7 @@ def _cmd_paths_edit_external(console, input_fn=None) -> None: continue candidate = Path(path_raw).expanduser() if not candidate.is_dir(): - console.print(f" Not a directory: {candidate}", style="bold #FF9AA2") + console.print(f" Not a directory: {candidate}", style=ERROR_STYLE) continue resolved = str(candidate.resolve()) if resolved not in paths.external: @@ -908,16 +945,16 @@ def _cmd_paths_edit_external(console, input_fn=None) -> None: try: index = int(index_raw) except ValueError: - console.print(" Enter a list number.", style="bold #FF9AA2") + console.print(" Enter a list number.", style=ERROR_STYLE) continue if index < 1 or index > len(paths.external): - console.print(" Invalid number.", style="bold #FF9AA2") + console.print(" Invalid number.", style=ERROR_STYLE) continue removed = paths.external.pop(index - 1) target = save_project_config(paths) console.print(f" Removed {removed}; saved to {target}", style=ID_STYLE) else: - console.print(" Unknown choice.", style="dim #FF9AA2") + console.print(" Unknown choice.", style=ERROR_DIM_STYLE) def cmd_paths_submenu( @@ -926,6 +963,7 @@ def cmd_paths_submenu( input_fn=None, ) -> Optional[str]: """Interactive paths submenu (menu option 4). Returns _NAV_EXIT to quit Skillware.""" + _apply_active_theme() if console is None: console = Console() @@ -969,7 +1007,7 @@ def cmd_paths_submenu( elif command == "flat": _cmd_paths_flat_diagnose(console, skills_root_override=skills_root_override) elif command is None: - console.print(f" Unknown choice: '{choice}'", style="dim #FF9AA2") + console.print(f" Unknown choice: '{choice}'", style=ERROR_DIM_STYLE) console.print() @@ -978,11 +1016,12 @@ def cmd_paths( console=None, ) -> int: """Show skill root resolution order, tiers, and shadowing (read-only config via skillware config show).""" + _apply_active_theme() if console is None: console = Console() cwd = Path.cwd().resolve() - console.print(Text("Skill path resolution", style=f"bold {TABLE_STYLE}")) + console.print(Text("Skill path resolution", style=TABLE_STYLE)) console.print(f" cwd: {cwd}", style="dim") console.print() @@ -991,7 +1030,7 @@ def cmd_paths( console.print( "No skill roots configured. Set " f"{SKILLWARE_SKILL_PATH_ENV} or use --skills-root.", - style="bold #FF9AA2", + style=ERROR_STYLE, ) return 1 @@ -1010,7 +1049,7 @@ def cmd_paths( for index, root in enumerate(roots, start=1): skill_count = len(list_registry_skill_ids(root.path)) if root.exists else 0 status = "ok" if root.exists else "missing" - status_style = ID_STYLE if root.exists else "bold #FF9AA2" + status_style = ID_STYLE if root.exists else ERROR_STYLE table.add_row( str(index), root.tier.value, @@ -1024,26 +1063,24 @@ def cmd_paths( conflicts = find_shadow_conflicts(roots) if conflicts: - console.print( - Text("Shadowing (first root wins on load)", style=f"bold {TABLE_STYLE}") - ) + console.print(Text("Shadowing (first root wins on load)", style=TABLE_STYLE)) for conflict in conflicts[:20]: console.print( f" {conflict.skill_id}: " f"{conflict.winner.tier.value} at {conflict.winner.path} " f"shadows {conflict.shadowed.tier.value} at {conflict.shadowed.path}", - style="bold #FF9AA2", + style=ERROR_STYLE, ) if len(conflicts) > 20: console.print(f" … and {len(conflicts) - 20} more", style="dim") console.print() - console.print(Text("Resolution order", style=f"bold {TABLE_STYLE}")) + console.print(Text("Resolution order", style=TABLE_STYLE)) for label, detail in resolution_order_summary(): console.print(f" {label}: {detail}", style="dim") console.print() - console.print(Text("Tips", style=f"bold {TABLE_STYLE}")) + console.print(Text("Tips", style=TABLE_STYLE)) console.print( " • One-shot override: skillware list --skills-root /path/to/skills", style=MENU_STYLE, @@ -1070,20 +1107,25 @@ def cmd_paths( def cmd_config_show(console=None) -> int: """Print merged global + project configuration (read-only).""" + _apply_active_theme() if console is None: console = Console() config = load_merged_config(refresh=True) paths = config.paths - console.print(Text("Skillware config", style=f"bold {TABLE_STYLE}")) + console.print(Text("Skillware config", style=TABLE_STYLE)) console.print() - console.print(Text("Config files", style=f"bold {TABLE_STYLE}")) + console.print(Text("Config files", style=TABLE_STYLE)) console.print(f" Global (default): {global_config_path()}", style="dim") for line in format_config_sources(config): console.print(f" Loaded: {line}", style=MENU_STYLE if config.layers else "dim") console.print() + console.print(Text("presentation (active)", style=TABLE_STYLE)) + console.print(f" theme: {config.presentation.theme}", style=MENU_STYLE) + console.print() + if not config.has_config_files: console.print( "No config files found — using legacy resolution " @@ -1103,7 +1145,7 @@ def cmd_config_show(console=None) -> int: console.print(line, style=MENU_STYLE) return 0 - console.print(Text("paths (active)", style=f"bold {TABLE_STYLE}")) + console.print(Text("paths (active)", style=TABLE_STYLE)) project_label = paths.project if paths.project is not None else "auto" console.print(f" project: {project_label}", style=MENU_STYLE) if paths.external: @@ -1127,7 +1169,7 @@ def cmd_config_show(console=None) -> int: console.print() if config.extra: - console.print(Text("Other sections (reserved)", style=f"bold {TABLE_STYLE}")) + console.print(Text("Other sections (reserved)", style=TABLE_STYLE)) for key in sorted(config.extra): console.print(f" {key}: (present, not applied yet)", style="dim") console.print() @@ -1137,12 +1179,60 @@ def cmd_config_show(console=None) -> int: style="dim", ) console.print( - "Edit via interactive menu (paths, mail) or YAML manually.", + "Edit via interactive menu (paths, mail, themes) or YAML manually.", style="dim", ) return 0 +def cmd_theme_picker(console=None, input_fn=None) -> Optional[str]: + """Select and persist a global CLI theme. Returns _NAV_EXIT when requested.""" + if console is None: + console = Console() + + choices = {key: name for key, name, _description in _THEME_CHOICES} + choices.update({name: name for _key, name, _description in _THEME_CHOICES}) + + while True: + _apply_active_theme() + current = load_merged_config().presentation.theme + console.print(Text("Theme", style=TABLE_STYLE)) + console.print(f" Current: {current}", style=ID_STYLE) + console.print() + for key, name, description in _THEME_CHOICES: + console.print( + f" [{key}] {name:<8}— {description}", + style=MENU_STYLE, + ) + _print_nav_footer(console, show_back=True) + + raw = _read_line(" theme> ", input_fn) + choice, nav = _parse_nav(raw) + if nav == _NAV_EXIT: + return _NAV_EXIT + if nav == _NAV_BACK: + return None + if not choice: + continue + + selected = choices.get(choice.lower()) + if selected is None: + console.print(f" Unknown theme: '{choice}'", style=ERROR_DIM_STYLE) + console.print() + continue + + target = save_global_presentation_theme(selected) + _apply_active_theme() + effective = load_merged_config().presentation.theme + console.print(f" Saved global theme '{selected}' to {target}", style=ID_STYLE) + if effective != selected: + console.print( + f" Project config keeps '{effective}' active in this directory.", + style="dim", + ) + return None + + def _doctor_load_target( skill_id: str, skills_root_override: Optional[Path] = None ) -> str: @@ -1214,6 +1304,7 @@ def cmd_doctor( console=None, ) -> int: """Check manifest deps and skill.py import without running execute().""" + _apply_active_theme() if console is None: console = Console(stderr=True) @@ -1223,7 +1314,7 @@ def cmd_doctor( category=category, ) if error: - console.print(error, style="bold #FF9AA2") + console.print(error, style=ERROR_STYLE) return 2 if skill_id and category else 1 table = Table( @@ -1251,7 +1342,7 @@ def cmd_doctor( sid, skills_root_override=skills_root_override ) except FileNotFoundError as exc: - console.print(str(exc), style="bold #FF9AA2") + console.print(str(exc), style=ERROR_STYLE) return 1 if deps_status != "ok" or load_status == "fail": @@ -1259,14 +1350,14 @@ def cmd_doctor( deps_cell = Text( deps_status, - style=ID_STYLE if deps_status == "ok" else "bold #FF9AA2", + style=ID_STYLE if deps_status == "ok" else ERROR_STYLE, ) if load_status == "skip": load_cell = Text("—", style="dim") elif load_status == "ok": load_cell = Text(load_status, style=ID_STYLE) else: - load_cell = Text(load_status, style="bold #FF9AA2") + load_cell = Text(load_status, style=ERROR_STYLE) rows.append((sid, deps_cell, load_cell, detail or "—")) @@ -1304,7 +1395,7 @@ def _prompt_examples_skill_id( if len(parts) != 2 or not all(parts): console.print( f" Invalid skill ID '{choice}'. Expected category/skill_name.", - style="dim #FF9AA2", + style=ERROR_DIM_STYLE, ) return None, _NAV_BACK return choice, None @@ -1318,24 +1409,25 @@ def _print_menu(console, menu) -> None: def cmd_help(console=None, *, brief: bool = True) -> None: """Print CLI help. Brief mode (default) shows topics + examples only.""" + _apply_active_theme() if console is None: console = Console() if brief: _print_help_index(console) _print_cli_usage_examples(console) - console.print(Text("Install", style=f"bold {TABLE_STYLE}")) + console.print(Text("Install", style=TABLE_STYLE)) console.print(" pip install skillware", style="dim") console.print() - console.print(Text("Docs", style=f"bold {TABLE_STYLE}")) + console.print(Text("Docs", style=TABLE_STYLE)) console.print( " https://github.com/arpahls/skillware/blob/main/docs/usage/cli.md", style=f"dim {SPLASH_STYLE}", ) console.print() - console.print(Text("Interactive mode", style=f"bold {TABLE_STYLE}")) + console.print(Text("Interactive mode", style=TABLE_STYLE)) console.print( - " skillware — menu 1-6; help topic drill-down via 6", style="dim" + " skillware — menu 1-8; help topic drill-down via 6", style="dim" ) console.print(" 0 — exit from any menu level", style="dim") console.print() @@ -1396,6 +1488,7 @@ def _package_version_str() -> str: def cmd_interactive(console=None, parser=None) -> None: """Launch ASCII splash screen and interactive menu.""" + _apply_active_theme() if console is None: console = Console() @@ -1425,6 +1518,7 @@ def cmd_interactive(console=None, parser=None) -> None: ("5", "doctor", "check manifest deps and skill.py import readiness"), ("6", "help", "grouped help topics and doc links"), ("7", "mail", "address book and signature for office/gmail_handler"), + ("8", "theme", "choose and save the CLI color theme"), ] commands = { @@ -1442,6 +1536,8 @@ def cmd_interactive(console=None, parser=None) -> None: "help": "help", "7": "mail", "mail": "mail", + "8": "theme", + "theme": "theme", } _print_menu(console, menu) @@ -1482,7 +1578,9 @@ def cmd_interactive(console=None, parser=None) -> None: elif command == "doctor": rc = cmd_doctor(console=console) if rc: - console.print(f" doctor exited with status {rc}", style="dim #FF9AA2") + console.print( + f" doctor exited with status {rc}", style=ERROR_DIM_STYLE + ) elif command == "mail": mail_nav = cmd_mail_submenu(console=console) if mail_nav == _NAV_EXIT: @@ -1493,8 +1591,13 @@ def cmd_interactive(console=None, parser=None) -> None: if help_nav == _NAV_EXIT: console.print(" Bye.", style="dim") return + elif command == "theme": + theme_nav = cmd_theme_picker(console=console) + if theme_nav == _NAV_EXIT: + console.print(" Bye.", style="dim") + return else: - console.print(f" Unknown command: '{choice}'", style="dim #FF9AA2") + console.print(f" Unknown command: '{choice}'", style=ERROR_DIM_STYLE) console.print() _print_menu(console, menu) diff --git a/skillware/cli_mail.py b/skillware/cli_mail.py index 27e4b66..2fae0a3 100644 --- a/skillware/cli_mail.py +++ b/skillware/cli_mail.py @@ -9,6 +9,7 @@ from rich.console import Console from rich.text import Text +from skillware.cli_theme import THEMES, active_theme from skillware.core.mail_config import ( DEFAULT_SIGNATURE_PLAIN, ENV_ADDRESSBOOK_PATH, @@ -43,9 +44,25 @@ ) from skillware.core.config import global_config_dir -TABLE_STYLE = "bold #C7CEEA" -ID_STYLE = "#B5EAD7" -MENU_STYLE = "#FFDAC1" +_DEFAULT_PALETTE = THEMES["pastel"] +TABLE_STYLE = _DEFAULT_PALETTE.heading_style +ID_STYLE = _DEFAULT_PALETTE.id_style +MENU_STYLE = _DEFAULT_PALETTE.menu_style +ERROR_STYLE = f"bold {_DEFAULT_PALETTE.error_color}" +ERROR_DIM_STYLE = f"dim {_DEFAULT_PALETTE.error_color}" + + +def _apply_active_theme() -> None: + """Refresh mail styles from the shared active CLI palette.""" + palette = active_theme() + global TABLE_STYLE, ID_STYLE, MENU_STYLE, ERROR_STYLE, ERROR_DIM_STYLE + + TABLE_STYLE = palette.heading_style + ID_STYLE = palette.id_style + MENU_STYLE = palette.menu_style + ERROR_STYLE = f"bold {palette.error_color}" + ERROR_DIM_STYLE = f"dim {palette.error_color}" + _NAV_EXIT = "exit" _NAV_BACK = "back" @@ -93,9 +110,8 @@ def _parse_nav(raw: Optional[str]) -> Tuple[str, Optional[str]]: def _print_mail_submenu(console: Console) -> None: - console.print( - Text("Mail settings (office/gmail_handler)", style=f"bold {TABLE_STYLE}") - ) + _apply_active_theme() + console.print(Text("Mail settings (office/gmail_handler)", style=TABLE_STYLE)) console.print( " Precedence: env > project YAML > global YAML > skill defaults", style="dim", @@ -111,13 +127,14 @@ def _print_mail_submenu(console: Console) -> None: def cmd_mail_addressbook_show(console: Optional[Console] = None) -> int: + _apply_active_theme() if console is None: console = Console() mail = load_merged_mail_settings(refresh=True) path = resolve_addressbook_path(mail=mail) data = load_addressbook_yaml(path) count = count_addressbook_contacts(data) - console.print(Text("Address book", style=f"bold {TABLE_STYLE}")) + console.print(Text("Address book", style=TABLE_STYLE)) console.print(f" path: {path}", style=ID_STYLE) console.print(f" contacts: {count}", style=MENU_STYLE) if path.is_file(): @@ -130,7 +147,7 @@ def cmd_mail_addressbook_show(console: Optional[Console] = None) -> int: elif "site-packages" in str(path): console.print( " storage: bundled wheel path — run addressbook init for a writable copy", - style="bold #FF9AA2", + style=ERROR_STYLE, ) else: console.print( @@ -146,6 +163,7 @@ def cmd_mail_addressbook_init( path: Optional[Path] = None, force: bool = False, ) -> int: + _apply_active_theme() if console is None: console = Console() target = path or default_global_addressbook_path() @@ -154,7 +172,7 @@ def cmd_mail_addressbook_init( except FileExistsError: console.print( f" Already exists: {target} (use --force to overwrite)", - style="bold #FF9AA2", + style=ERROR_STYLE, ) return 1 saved = persist_global_mail_paths(addressbook_path=str(target)) @@ -193,6 +211,7 @@ def cmd_mail_addressbook_add( org: Optional[str] = None, contact_id: Optional[str] = None, ) -> int: + _apply_active_theme() if console is None: console = Console() @@ -250,7 +269,7 @@ def cmd_mail_addressbook_add( contact_id=cid, ) except ValueError as exc: - console.print(f" {exc}", style="bold #FF9AA2") + console.print(f" {exc}", style=ERROR_STYLE) return 1 console.print(f" Added contact {saved_id!r} → {path}", style=ID_STYLE) @@ -258,17 +277,18 @@ def cmd_mail_addressbook_add( def cmd_mail_addressbook_validate(console: Optional[Console] = None) -> int: + _apply_active_theme() if console is None: console = Console() mail = load_merged_mail_settings(refresh=True) path = resolve_addressbook_path(mail=mail) if not path.is_file(): - console.print(f" Missing address book: {path}", style="bold #FF9AA2") + console.print(f" Missing address book: {path}", style=ERROR_STYLE) return 1 data = load_addressbook_yaml(path) errors = validate_addressbook_data(data) if errors: - console.print(Text("Address book validation failed", style="bold #FF9AA2")) + console.print(Text("Address book validation failed", style=ERROR_STYLE)) for error in errors: console.print(f" • {error}") return 1 @@ -282,6 +302,7 @@ def cmd_mail_addressbook_set_path( path: Optional[str] = None, input_fn: ReadLineFn = None, ) -> int: + _apply_active_theme() if console is None: console = Console() raw = path @@ -294,7 +315,7 @@ def cmd_mail_addressbook_set_path( return 1 raw = raw.strip() if not raw: - console.print(" Path required.", style="bold #FF9AA2") + console.print(" Path required.", style=ERROR_STYLE) return 1 project_mail = load_project_mail_settings() @@ -305,12 +326,13 @@ def cmd_mail_addressbook_set_path( def cmd_mail_signature_show(console: Optional[Console] = None) -> int: + _apply_active_theme() if console is None: console = Console() mail = load_merged_mail_settings(refresh=True) text, source = resolve_signature_plain(mail=mail) html, html_source = resolve_signature_html(mail=mail) - console.print(Text("Mail signature", style=f"bold {TABLE_STYLE}")) + console.print(Text("Mail signature", style=TABLE_STYLE)) console.print(f" plain source: {source}", style=MENU_STYLE) console.print(f" html source: {html_source}", style=MENU_STYLE) if not text and not html: @@ -337,6 +359,7 @@ def cmd_mail_signature_init( inline: bool = False, force: bool = False, ) -> int: + _apply_active_theme() if console is None: console = Console() config_dir = global_config_dir() @@ -361,7 +384,7 @@ def cmd_mail_signature_init( except FileExistsError as exc: console.print( f" Already exists: {exc} (use --force to overwrite)", - style="bold #FF9AA2", + style=ERROR_STYLE, ) return 1 @@ -388,12 +411,13 @@ def cmd_mail_signature_set( text: Optional[str] = None, input_fn: ReadLineFn = None, ) -> int: + _apply_active_theme() if console is None: console = Console() if file_path is not None: if not file_path.is_file(): - console.print(f" File not found: {file_path}", style="bold #FF9AA2") + console.print(f" File not found: {file_path}", style=ERROR_STYLE) return 1 body = file_path.read_text(encoding="utf-8").strip() project_mail = load_project_mail_settings() @@ -402,7 +426,7 @@ def cmd_mail_signature_set( save_project_mail_settings(project_mail) errors = validate_signature_text(body) if errors: - console.print(" Warning:", style="bold #FF9AA2") + console.print(" Warning:", style=ERROR_STYLE) for error in errors: console.print(f" • {error}") console.print( @@ -425,7 +449,7 @@ def cmd_mail_signature_set( errors = validate_signature_text(text or "") if errors: - console.print(" Invalid signature:", style="bold #FF9AA2") + console.print(" Invalid signature:", style=ERROR_STYLE) for error in errors: console.print(f" • {error}") return 1 @@ -439,13 +463,14 @@ def cmd_mail_signature_set( def cmd_mail_signature_validate(console: Optional[Console] = None) -> int: + _apply_active_theme() if console is None: console = Console() mail = load_merged_mail_settings(refresh=True) text, source = resolve_signature_plain(mail=mail) errors = validate_signature_text(text) if errors: - console.print(Text("Signature validation failed", style="bold #FF9AA2")) + console.print(Text("Signature validation failed", style=ERROR_STYLE)) console.print(f" source: {source}", style="dim") for error in errors: console.print(f" • {error}") @@ -455,6 +480,7 @@ def cmd_mail_signature_validate(console: Optional[Console] = None) -> int: def cmd_mail_signature_clear(console: Optional[Console] = None) -> int: + _apply_active_theme() if console is None: console = Console() updated = clear_mail_signature_settings() @@ -471,12 +497,13 @@ def cmd_mail_signature_clear(console: Optional[Console] = None) -> int: def cmd_mail_signature_profiles(console: Optional[Console] = None) -> int: + _apply_active_theme() if console is None: console = Console() mail = load_merged_mail_settings(refresh=True) active = resolve_signature_profile(mail=mail) profiles = list_signature_profiles(mail=mail) - console.print(Text("Signature profiles", style=f"bold {TABLE_STYLE}")) + console.print(Text("Signature profiles", style=TABLE_STYLE)) console.print(f" active: {active}", style=ID_STYLE) if not profiles: console.print(" (none — use signature init or add-profile)", style="dim") @@ -501,13 +528,14 @@ def cmd_mail_signature_set_profile( profile_id: Optional[str] = None, input_fn: ReadLineFn = None, ) -> int: + _apply_active_theme() if console is None: console = Console() pid = (profile_id or "").strip() if not pid: pid = (_read_line(" Profile id: ", input_fn) or "").strip() if not pid: - console.print(" profile id is required.", style="bold #FF9AA2") + console.print(" profile id is required.", style=ERROR_STYLE) return 1 target = set_active_signature_profile(pid) console.print(f" Active signature profile: {pid}", style=ID_STYLE) @@ -523,25 +551,26 @@ def cmd_mail_signature_add_profile( plain_path: Optional[Path] = None, input_fn: ReadLineFn = None, ) -> int: + _apply_active_theme() if console is None: console = Console() pid = (profile_id or "").strip() if not pid: pid = (_read_line(" Profile id: ", input_fn) or "").strip() if not pid: - console.print(" profile id is required.", style="bold #FF9AA2") + console.print(" profile id is required.", style=ERROR_STYLE) return 1 if html_path is None and plain_path is None: console.print( " Provide --html and/or --plain path to signature files.", - style="bold #FF9AA2", + style=ERROR_STYLE, ) return 1 if plain_path is not None and not plain_path.is_file(): - console.print(f" Plain file not found: {plain_path}", style="bold #FF9AA2") + console.print(f" Plain file not found: {plain_path}", style=ERROR_STYLE) return 1 if html_path is not None and not html_path.is_file(): - console.print(f" HTML file not found: {html_path}", style="bold #FF9AA2") + console.print(f" HTML file not found: {html_path}", style=ERROR_STYLE) return 1 target = upsert_signature_profile( @@ -558,10 +587,11 @@ def cmd_mail_signature_add_profile( def cmd_mail_show(console: Optional[Console] = None) -> int: """Summary of resolved mail settings (read-only).""" + _apply_active_theme() if console is None: console = Console() mail = load_merged_mail_settings(refresh=True) - console.print(Text("Mail settings (resolved)", style=f"bold {TABLE_STYLE}")) + console.print(Text("Mail settings (resolved)", style=TABLE_STYLE)) for line in format_mail_config_lines(mail): console.print(line, style=MENU_STYLE) console.print() @@ -582,6 +612,7 @@ def cmd_mail_submenu( console: Optional[Console] = None, input_fn: ReadLineFn = None ) -> Optional[str]: """Interactive mail submenu. Returns _NAV_EXIT to quit Skillware.""" + _apply_active_theme() if console is None: console = Console() @@ -653,9 +684,9 @@ def cmd_mail_submenu( elif command == "signature add-profile": cmd_mail_signature_add_profile(console, input_fn=input_fn) elif command is None: - console.print(f" Unknown choice: {choice!r}", style="dim #FF9AA2") + console.print(f" Unknown choice: {choice!r}", style=ERROR_DIM_STYLE) else: - console.print(f" Unknown choice: {choice!r}", style="dim #FF9AA2") + console.print(f" Unknown choice: {choice!r}", style=ERROR_DIM_STYLE) console.print() @@ -666,6 +697,7 @@ def cmd_mail( **kwargs, ) -> int: """Dispatch skillware mail [area] [action].""" + _apply_active_theme() if console is None: console = Console() @@ -698,7 +730,7 @@ def cmd_mail( ) if action == "set-path": return cmd_mail_addressbook_set_path(console, path=kwargs.get("path")) - console.print(f" Unknown addressbook action: {action}", style="bold #FF9AA2") + console.print(f" Unknown addressbook action: {action}", style=ERROR_STYLE) return 1 if area == "signature": @@ -735,9 +767,9 @@ def cmd_mail( html_path=kwargs.get("html_path"), plain_path=kwargs.get("plain_path"), ) - console.print(f" Unknown signature action: {action}", style="bold #FF9AA2") + console.print(f" Unknown signature action: {action}", style=ERROR_STYLE) return 1 - console.print(f" Unknown mail area: {area}", style="bold #FF9AA2") + console.print(f" Unknown mail area: {area}", style=ERROR_STYLE) console.print(" Use: addressbook | signature", style="dim") return 1 diff --git a/skillware/cli_theme.py b/skillware/cli_theme.py new file mode 100644 index 0000000..5a0d947 --- /dev/null +++ b/skillware/cli_theme.py @@ -0,0 +1,70 @@ +"""Shared presentation themes for Skillware CLI modules.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, Tuple + +from skillware.core.config import load_merged_config + + +@dataclass(frozen=True) +class ThemePalette: + """Semantic Rich styles for one built-in CLI theme.""" + + heading_style: str + category_style: str + id_style: str + border_style: str + splash_style: str + menu_style: str + error_color: str + gradient_start: Tuple[int, int, int] + gradient_mid: Tuple[int, int, int] + gradient_end: Tuple[int, int, int] + + +THEMES: Dict[str, ThemePalette] = { + "pastel": ThemePalette( + heading_style="bold #C7CEEA", + category_style="bold #FFDAC1", + id_style="#B5EAD7", + border_style="#C7CEEA", + splash_style="#C7CEEA", + menu_style="#FFDAC1", + error_color="#FF9AA2", + gradient_start=(0xD4, 0xE4, 0xF1), + gradient_mid=(0x79, 0xB6, 0xD8), + gradient_end=(0xEB, 0xD8, 0xDC), + ), + "ocean": ThemePalette( + heading_style="bold #7DD3FC", + category_style="bold #38BDF8", + id_style="#BAE6FD", + border_style="#0284C7", + splash_style="#38BDF8", + menu_style="#7DD3FC", + error_color="#F87171", + gradient_start=(0x0C, 0x4A, 0x6E), + gradient_mid=(0x02, 0x84, 0xC7), + gradient_end=(0x7D, 0xD3, 0xFC), + ), + "mono": ThemePalette( + heading_style="bold #D0D0D0", + category_style="bold #A8A8A8", + id_style="#E0E0E0", + border_style="#808080", + splash_style="#C0C0C0", + menu_style="#A8A8A8", + error_color="#B0B0B0", + gradient_start=(0xF0, 0xF0, 0xF0), + gradient_mid=(0xA0, 0xA0, 0xA0), + gradient_end=(0x60, 0x60, 0x60), + ), +} + + +def active_theme() -> ThemePalette: + """Return the configured palette; config normalization guarantees fallback.""" + theme_name = load_merged_config().presentation.theme + return THEMES.get(theme_name, THEMES["pastel"]) diff --git a/skillware/core/config.py b/skillware/core/config.py index c606a9a..48410a8 100644 --- a/skillware/core/config.py +++ b/skillware/core/config.py @@ -18,11 +18,14 @@ GLOBAL_CONFIG_DIR_ENV = "SKILLWARE_CONFIG_DIR" PROJECT_CONFIG_FILENAME = ".skillware.yaml" GLOBAL_CONFIG_FILENAME = "config.yaml" +DEFAULT_PRESENTATION_THEME = "pastel" +SUPPORTED_PRESENTATION_THEMES: Tuple[str, ...] = ("pastel", "ocean", "mono") _MAX_PARENT_WALK = 6 _DEFAULT_RESOLUTION_ORDER: Tuple[str, ...] = ("project", "external", "bundled") _VALID_ORDER_TIERS = frozenset({"project", "external", "bundled"}) -_KNOWN_TOP_LEVEL_KEYS = frozenset({"paths", "resolution", "legacy", "mail"}) +_PATH_TOP_LEVEL_KEYS = frozenset({"paths", "resolution", "legacy"}) +_KNOWN_TOP_LEVEL_KEYS = _PATH_TOP_LEVEL_KEYS | {"mail", "presentation"} @dataclass(frozen=True) @@ -46,18 +49,26 @@ def project_is_auto(self) -> bool: return self.project is None or str(self.project).strip().lower() == "auto" +@dataclass +class PresentationSettings: + """CLI presentation settings from merged configuration.""" + + theme: str = DEFAULT_PRESENTATION_THEME + + @dataclass class SkillwareConfig: """ Merged Skillware configuration. - ``paths`` and ``mail`` are implemented today. Additional top-level YAML - sections (for example ``theme``, ``chains``) are preserved in ``extra`` - for forward compatibility and shown by ``skillware config show``. + ``paths``, ``mail``, and ``presentation`` are active settings. Additional top-level + YAML sections (for example ``chains`` and skill presets) are preserved in + ``extra`` for forward compatibility and shown by ``skillware config show``. """ paths: PathsSettings = field(default_factory=PathsSettings) mail: MailSettings = field(default_factory=MailSettings) + presentation: PresentationSettings = field(default_factory=PresentationSettings) extra: Dict[str, Any] = field(default_factory=dict) layers: Tuple[ConfigLayer, ...] = () @@ -136,6 +147,16 @@ def _parse_resolution_order(raw: Any) -> Tuple[str, ...]: return tuple(tiers) if tiers else _DEFAULT_RESOLUTION_ORDER +def normalize_presentation_theme(raw: Any) -> str: + """Return a supported theme name, falling back safely to pastel.""" + if not isinstance(raw, str): + return DEFAULT_PRESENTATION_THEME + theme = raw.strip().lower() + if theme not in SUPPORTED_PRESENTATION_THEMES: + return DEFAULT_PRESENTATION_THEME + return theme + + def _layer_from_file(path: Path) -> ConfigLayer: return ConfigLayer(path=path.resolve(), data=_read_yaml(path)) @@ -153,6 +174,7 @@ def _merge_extra_section( def _merge_layers(layers: Sequence[ConfigLayer]) -> SkillwareConfig: paths = PathsSettings() + presentation = PresentationSettings() mail_layers: List[MailSettings] = [] extra: Dict[str, Any] = {} @@ -194,8 +216,23 @@ def _merge_layers(layers: Sequence[ConfigLayer]) -> SkillwareConfig: legacy_block.get("honor_skillware_skill_path") ) + if "presentation" in layer.data: + presentation_block = layer.data.get("presentation") + if isinstance(presentation_block, dict) and "theme" in presentation_block: + presentation.theme = normalize_presentation_theme( + presentation_block.get("theme") + ) + elif not isinstance(presentation_block, dict): + presentation.theme = DEFAULT_PRESENTATION_THEME + mail = merge_mail_settings(mail_layers) - return SkillwareConfig(paths=paths, mail=mail, extra=extra, layers=tuple(layers)) + return SkillwareConfig( + paths=paths, + mail=mail, + presentation=presentation, + extra=extra, + layers=tuple(layers), + ) def load_merged_config(*, refresh: bool = False) -> SkillwareConfig: @@ -283,7 +320,7 @@ def save_project_config( if preserve_extra and target.is_file(): existing = _read_yaml(target) for key, value in existing.items(): - if key not in _KNOWN_TOP_LEVEL_KEYS: + if key not in _PATH_TOP_LEVEL_KEYS: document[key] = value target.write_text( @@ -292,3 +329,27 @@ def save_project_config( ) clear_config_cache() return target.resolve() + + +def save_global_presentation_theme(theme: str) -> Path: + """Persist a supported presentation theme without replacing other settings.""" + normalized = str(theme).strip().lower() + if normalized not in SUPPORTED_PRESENTATION_THEMES: + supported = ", ".join(SUPPORTED_PRESENTATION_THEMES) + raise ValueError(f"Unknown presentation theme '{theme}'. Choose: {supported}.") + + target = global_config_path() + target.parent.mkdir(parents=True, exist_ok=True) + document = dict(_read_yaml(target)) if target.is_file() else {} + + raw_presentation = document.get("presentation") + presentation = dict(raw_presentation) if isinstance(raw_presentation, dict) else {} + presentation["theme"] = normalized + document["presentation"] = presentation + + target.write_text( + yaml.safe_dump(document, sort_keys=False, default_flow_style=False), + encoding="utf-8", + ) + clear_config_cache() + return target.resolve() diff --git a/tests/test_cli.py b/tests/test_cli.py index 50c7cdc..37d5b5f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -17,12 +17,28 @@ cmd_paths, cmd_paths_submenu, cmd_doctor, + cmd_theme_picker, ) import importlib.util import pytest +from skillware.core.config import clear_config_cache + + +@pytest.fixture +def isolated_theme_environment(tmp_path, monkeypatch): + """Keep theme reads and writes away from the user's real configuration.""" + repo = tmp_path / "repo" + repo.mkdir() + config_dir = tmp_path / "global-config" + monkeypatch.chdir(repo) + monkeypatch.setenv("SKILLWARE_CONFIG_DIR", str(config_dir)) + clear_config_cache() + yield repo, config_dir + clear_config_cache() + def test_discover_skills_returns_skills(tmp_path): # Create a fake skill directory structure @@ -282,6 +298,175 @@ def test_interactive_help_dispatches_to_cmd_help(monkeypatch): assert "--issuer" in output +def test_builtin_theme_palettes_match_intended_roles(): + import skillware.cli as cli + + assert set(cli.THEMES) == {"pastel", "ocean", "mono"} + + pastel = cli.THEMES["pastel"] + assert pastel.heading_style == "bold #C7CEEA" + assert pastel.menu_style == "#FFDAC1" + assert pastel.gradient_mid == (0x79, 0xB6, 0xD8) + + ocean = cli.THEMES["ocean"] + for red, green, blue in ( + ocean.gradient_start, + ocean.gradient_mid, + ocean.gradient_end, + ): + assert blue > red + assert blue >= green + + mono = cli.THEMES["mono"] + for red, green, blue in ( + mono.gradient_start, + mono.gradient_mid, + mono.gradient_end, + ): + assert red == green == blue + + +@pytest.mark.parametrize("theme_name", ["pastel", "ocean", "mono"]) +def test_builtin_theme_smoke_render(theme_name, isolated_theme_environment): + import io + + from rich.console import Console + + import skillware.cli as cli + from skillware.core.config import save_global_presentation_theme + + save_global_presentation_theme(theme_name) + buf = io.StringIO() + cmd_help(console=Console(file=buf, force_terminal=False)) + + palette = cli.THEMES[theme_name] + assert cli._active_theme() == palette + assert cli.TABLE_STYLE == palette.heading_style + assert cli.MENU_STYLE == palette.menu_style + assert cli.ERROR_STYLE == f"bold {palette.error_color}" + assert cli._gradient_splash_text(("TEST",)).plain == "TEST\n" + assert "Topics" in buf.getvalue() + + +@pytest.mark.parametrize("theme_name", ["pastel", "ocean", "mono"]) +def test_mail_submenu_uses_active_theme(theme_name, isolated_theme_environment): + import io + + from rich.console import Console + + import skillware.cli_mail as cli_mail + from skillware.cli_theme import THEMES + from skillware.core.config import save_global_presentation_theme + + save_global_presentation_theme(theme_name) + buf = io.StringIO() + + assert ( + cli_mail.cmd_mail_submenu( + console=Console(file=buf, force_terminal=False), + input_fn=lambda _prompt: "b", + ) + is None + ) + + palette = THEMES[theme_name] + assert cli_mail.TABLE_STYLE == palette.heading_style + assert cli_mail.ID_STYLE == palette.id_style + assert cli_mail.MENU_STYLE == palette.menu_style + assert cli_mail.ERROR_STYLE == f"bold {palette.error_color}" + assert "Mail settings" in buf.getvalue() + + +def test_theme_picker_persists_and_preserves_global_config( + isolated_theme_environment, +): + import io + + import yaml + from rich.console import Console + + import skillware.cli as cli + + _repo, config_dir = isolated_theme_environment + config_path = config_dir / "config.yaml" + config_dir.mkdir() + config_path.write_text( + "paths:\n project: auto\nchains:\n default: []\n", + encoding="utf-8", + ) + responses = iter(["unknown", "2"]) + buf = io.StringIO() + + assert ( + cmd_theme_picker( + console=Console(file=buf, force_terminal=False), + input_fn=lambda _prompt: next(responses), + ) + is None + ) + + saved = yaml.safe_load(config_path.read_text(encoding="utf-8")) + assert saved["presentation"]["theme"] == "ocean" + assert saved["paths"] == {"project": "auto"} + assert saved["chains"] == {"default": []} + assert cli.TABLE_STYLE == cli.THEMES["ocean"].heading_style + assert "Unknown theme: 'unknown'" in buf.getvalue() + assert "Saved global theme 'ocean'" in buf.getvalue() + + +def test_interactive_theme_dispatches_and_refreshes( + isolated_theme_environment, monkeypatch +): + import io + + import yaml + from rich.console import Console + + import skillware.cli as cli + + _repo, config_dir = isolated_theme_environment + responses = iter(["8", "3", "q"]) + monkeypatch.setattr("builtins.input", lambda _prompt: next(responses)) + buf = io.StringIO() + + cmd_interactive(console=Console(file=buf, force_terminal=False)) + + saved = yaml.safe_load((config_dir / "config.yaml").read_text(encoding="utf-8")) + assert saved["presentation"]["theme"] == "mono" + assert cli.TABLE_STYLE == cli.THEMES["mono"].heading_style + assert "Current: pastel" in buf.getvalue() + assert "Saved global theme 'mono'" in buf.getvalue() + + +def test_theme_picker_reports_project_override(isolated_theme_environment): + import io + + import yaml + from rich.console import Console + + from skillware.core.config import load_merged_config + + repo, config_dir = isolated_theme_environment + (repo / ".skillware.yaml").write_text( + "presentation:\n theme: ocean\n", + encoding="utf-8", + ) + clear_config_cache() + buf = io.StringIO() + + cmd_theme_picker( + console=Console(file=buf, force_terminal=False), + input_fn=lambda _prompt: "3", + ) + + global_config = yaml.safe_load( + (config_dir / "config.yaml").read_text(encoding="utf-8") + ) + assert global_config["presentation"]["theme"] == "mono" + assert load_merged_config().presentation.theme == "ocean" + assert "Project config keeps 'ocean' active" in buf.getvalue() + + def test_version_flag(capsys): """skillware --version should print the installed version and exit.""" import sys diff --git a/tests/test_config.py b/tests/test_config.py index 65c6468..5cad9ac 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -3,8 +3,10 @@ from pathlib import Path import pytest +import yaml from skillware.core.config import ( + DEFAULT_PRESENTATION_THEME, GLOBAL_CONFIG_DIR_ENV, PROJECT_CONFIG_FILENAME, PathsSettings, @@ -12,6 +14,7 @@ find_project_config_file, load_merged_config, load_project_paths_settings, + save_global_presentation_theme, save_project_config, ) from skillware.core.discovery import ( @@ -167,6 +170,57 @@ def test_global_and_project_config_merge(tmp_path, monkeypatch): assert Path(config.paths.external[1]) == project_external.resolve() +def test_presentation_theme_defaults_to_pastel(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.setenv(GLOBAL_CONFIG_DIR_ENV, str(tmp_path / "no-global")) + + config = load_merged_config(refresh=True) + + assert config.presentation.theme == DEFAULT_PRESENTATION_THEME + + +def test_project_presentation_theme_overrides_global(tmp_path, monkeypatch): + global_dir = tmp_path / "global-config" + _write_config( + global_dir / "config.yaml", + "presentation:\n theme: ocean\n", + ) + repo = tmp_path / "repo" + repo.mkdir() + _write_config( + repo / PROJECT_CONFIG_FILENAME, + "presentation:\n theme: mono\n", + ) + monkeypatch.chdir(repo) + monkeypatch.setenv(GLOBAL_CONFIG_DIR_ENV, str(global_dir)) + + config = load_merged_config(refresh=True) + + assert config.presentation.theme == "mono" + + +@pytest.mark.parametrize("configured", ["unknown", "", None, ["pastel"]]) +def test_unknown_or_malformed_presentation_theme_falls_back( + tmp_path, monkeypatch, configured +): + repo = tmp_path / "repo" + repo.mkdir() + config_path = repo / PROJECT_CONFIG_FILENAME + config_path.write_text( + yaml.safe_dump( + {"presentation": {"theme": configured}}, + sort_keys=False, + ), + encoding="utf-8", + ) + monkeypatch.chdir(repo) + monkeypatch.setenv(GLOBAL_CONFIG_DIR_ENV, str(tmp_path / "no-global")) + + config = load_merged_config(refresh=True) + + assert config.presentation.theme == DEFAULT_PRESENTATION_THEME + + def test_find_project_config_walks_up(tmp_path, monkeypatch): repo = tmp_path / "repo" nested = repo / "a" / "b" @@ -274,3 +328,52 @@ def test_save_project_config_persists_paths(tmp_path, monkeypatch): merged = load_merged_config(refresh=True) assert merged.has_config_files assert any(root.path == external.resolve() for root in get_skill_roots()) + + +def test_save_project_config_preserves_presentation(tmp_path, monkeypatch): + repo = tmp_path / "repo" + repo.mkdir() + _write_config( + repo / PROJECT_CONFIG_FILENAME, + "presentation:\n theme: ocean\n", + ) + monkeypatch.chdir(repo) + + save_project_config(PathsSettings(project="auto")) + + saved = yaml.safe_load((repo / PROJECT_CONFIG_FILENAME).read_text(encoding="utf-8")) + assert saved["presentation"]["theme"] == "ocean" + + +def test_save_global_presentation_theme_preserves_unrelated_settings( + tmp_path, monkeypatch +): + global_dir = tmp_path / "global-config" + global_path = global_dir / "config.yaml" + _write_config( + global_path, + "paths:\n project: auto\n" + "presentation:\n theme: pastel\n contrast: high\n" + "chains:\n default: []\n", + ) + monkeypatch.setenv(GLOBAL_CONFIG_DIR_ENV, str(global_dir)) + load_merged_config(refresh=True) + + written = save_global_presentation_theme(" OCEAN ") + + assert written == global_path.resolve() + saved = yaml.safe_load(global_path.read_text(encoding="utf-8")) + assert saved["presentation"] == {"theme": "ocean", "contrast": "high"} + assert saved["paths"] == {"project": "auto"} + assert saved["chains"] == {"default": []} + assert load_merged_config().presentation.theme == "ocean" + + +def test_save_global_presentation_theme_rejects_unknown(tmp_path, monkeypatch): + global_dir = tmp_path / "global-config" + monkeypatch.setenv(GLOBAL_CONFIG_DIR_ENV, str(global_dir)) + + with pytest.raises(ValueError, match="Unknown presentation theme"): + save_global_presentation_theme("neon") + + assert not (global_dir / "config.yaml").exists()