diff --git a/CHANGELOG.md b/CHANGELOG.md index b28247a..e5bf5ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,52 @@ changes — they are called out in the **Breaking** section. ## [Unreleased] +### Added + +- **`superton export` / `superton import-palace`.** Back up every drawer + as JSON Lines (`superton export -o palace.jsonl`, or stream to stdout) + and restore it on any machine. Drawer ids are content-addressed, so + re-importing is idempotent — duplicates are counted, not re-inserted. +- **`--json` output mode** for `list`, `search`, `sources`, `stats`, + `recent`, and `today` — pipe palace data straight into `jq` or scripts + (the roadmap's Phase-3 "JSON output mode", landed early). +- **`aurora` theme** — teal on black, northern-lights palette. The fifth + hand-tuned theme, with its own `❖` prompt glyph, spinner, and rule + character. +- **Grouped `superton --help`.** Commands are now organized into "Start + here", "Ingest & capture", "Ask & explore", "Palace management", and + "Model & system" panels, with an examples footer. Also: `-V` as a + short version flag. +- **Shell: typo guard with "did you mean".** Mistyped slash commands + (`/serach`, `/them`) no longer fall through to the model as chat — + the shell suggests the closest command instead. Bare commands that + need an argument (`/add`) now print their usage line. +- **Shell: `/list`, `/recent`, `/note `, and `/why`.** Recent + drawers, recently added sources, and quick note capture without + leaving the REPL; `/why` toggles a retrieval trace under every answer. +- **Shell: grouped `/help` cheatsheet** — a themed, four-group command + map replacing the old one-line dump — and **tab path completion** for + `/add` and `/refresh` arguments. +- **`NO_COLOR` / `SUPERTON_NO_COLOR`.** Honors the no-color.org + convention plus a scoped override for colorless-but-interactive + terminals. + +### Fixed + +- `superton doctor` misreported uv installs as `pip`: it resolved the + interpreter symlink out of uv's tool venv before matching the path — + the same bug `superton uninstall` fixed earlier. Doctor now checks + `sys.prefix` and the raw executable path. +- `superton add ` counted re-ingested (deduped) chunks as newly + ingested drawers; it now reports `N drawers · M deduped` like file + ingestion does. +- The `ConfigError` recovery hint pointed at `~/.superton/config.toml`, + which was never the config location; it now points at + `superton doctor`, which prints the real path. +- Removed shadowing duplicate `ANSWER_CONTEXT_DRAWERS` / + `ANSWER_DRAWER_CHARS` constants in `superton/shell.py` — the canonical + values live in `superton.chat`. + ### Removed (BREAKING) - **Textual TUI.** `superton tui`, the `[tui]` optional extra, the diff --git a/README.md b/README.md index 40fff0d..4533f95 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ The interesting choices, with the tradeoffs called out: generating a plausible-but-wrong answer from the model's parametric memory. The user gets a "did you mean…" with the closest source files instead. -- **Themes as a UX commitment, not a skin.** Four hand-tuned palettes +- **Themes as a UX commitment, not a skin.** Five hand-tuned palettes share an icon and styling vocabulary (✓ ! ✗ ℹ → ›). Paths, drawer ids, commands, and keyboard hints route through `ui.style_*` helpers so switching themes looks intentional, not skinned. @@ -220,6 +220,9 @@ superton welcome # anytime tour of what's installed # power tools superton mcp serve # expose the palace to Claude / Cursor / Gemini superton dedup --dry-run # find near-duplicate drawers +superton export -o palace.jsonl # back up every drawer (JSON Lines) +superton import-palace palace.jsonl # restore on any machine +superton search "throttling" --json | jq . # script-friendly output superton close # stop local model runners # or launch the interactive shell — type /stop to unload Superton, /quit to stop + exit @@ -249,7 +252,11 @@ sources Inside the shell, `/clear` resets the conversation, `/theme ` swaps the palette, and `/model` shows Superton's model configuration with a brief -confirmation flash. +confirmation flash. `/list` and `/recent` show what's in the palace, `/note +` captures a quick thought, and `/why` toggles a retrieval trace under +every answer. Tab-completion covers slash commands, theme names, and file +paths after `/add` / `/refresh` — and a mistyped command gets a +"did you mean /search?" nudge instead of being sent to the model. ## Web Puller @@ -283,17 +290,19 @@ Features: | `superton welcome` | Show the header + palace intro + next-steps card any time | | `superton add ` | Ingest a file or directory | | `superton ask "..."` | Query Superton with palace context (streaming + citations) | -| `superton list` | Show recent drawers | +| `superton list` | Show recent drawers (`--json` for scripts) | | `superton search "..."` | Hybrid search via MemPalace with SQLite fallback | | `superton forget ` | Remove a drawer | | `superton forget-source ` | Remove all drawers from one source | | `superton refresh ` | Reingest a source and remove stale chunks | | `superton sources` | List indexed source files | -| `superton stats` | Palace statistics | +| `superton stats` | Palace statistics (`--json` for scripts) | | `superton doctor` | Check local runtime, memory, theme, and model setup | | `superton reindex` | Rebuild semantic index from stored drawers | | `superton model` | Show the Superton model configuration | -| `superton theme [ember\|crimson\|void\|ash]` | Show or switch the CLI theme | +| `superton theme [ember\|crimson\|void\|ash\|aurora]` | Show or switch the CLI theme | +| `superton export [-o file]` | Back up every drawer as JSON Lines | +| `superton import-palace ` | Restore drawers from a `superton export` file | | `superton dedup [--dry-run \| --apply]` | Find near-duplicate drawers (via MemPalace dedup) | | `superton mcp serve` | Run the MemPalace MCP server against the SuperTon palace | | `superton close` | Stop running SuperTon model runners | @@ -352,7 +361,7 @@ base models. ## Themes -SuperTon ships with four hand-tuned CLI themes: +SuperTon ships with five hand-tuned CLI themes: | Theme | Vibe | |---|---| @@ -360,9 +369,10 @@ SuperTon ships with four hand-tuned CLI themes: | `crimson` | blood red on black · dark and sharp | | `void` | dark violet · deep-space palette | | `ash` | white on black · stark monochrome | +| `aurora` | teal on black · northern lights | Each theme carries its own design language, not just colors: a unique -prompt glyph (`◉ ✦ ◈ ›`), spinner style, and rule character — `ember` +prompt glyph (`◉ ✦ ◈ › ❖`), spinner style, and rule character — `ember` draws solid gradient rules, `crimson` dotted, `void` dashed. Section headers sweep muted → primary as they appear, install stages tick in with a 3-frame pulse, and the stage tracker shows `●●○○○` progress dots. @@ -445,7 +455,7 @@ four characters so the report is shareable. | variable | purpose | default | |---|---|---| | `SUPERTON_HOME` | override palace location | platform-specific | -| `SUPERTON_THEME` | `ember \| crimson \| void \| ash` | `ember` | +| `SUPERTON_THEME` | `ember \| crimson \| void \| ash \| aurora` | `ember` | | `SUPERTON_MODEL_BACKEND` | `auto \| ollama \| huggingface` | `auto` | | `SUPERTON_MEMORY_BACKEND` | `hybrid \| semantic \| mempalace \| sqlite` | `hybrid` | | `SUPERTON_BASE_MODEL` | override the Ollama base tag | `openbmb/minicpm5` | @@ -453,6 +463,7 @@ four characters so the report is shareable. | `OLLAMA_HOST` | Ollama daemon URL | `http://127.0.0.1:11434` | | `HF_TOKEN` | enables Hugging Face fallback | — | | `SUPERTON_LOG` | `debug \| info \| warn \| error \| off` | `warn` | +| `NO_COLOR` / `SUPERTON_NO_COLOR` | disable ANSI color entirely | — | | `SUPERTON_LOG_JSON` | one JSON record per line on stderr | off | | `SUPERTON_LOG_FILE` | tee structured logs to a file | — | @@ -482,11 +493,11 @@ superton doctor - **Phase 1.5** — production hardening: structured logging, typed errors with recovery hints, mypy-strict core, 231-test suite, Linux+macOS CI, UI polish pass (verb-cycling spinners, cards, pills, diff refresh) ✅ -- **Phase 2** *(current)* — `timeline` / `entities` via MemPalace knowledge - graph, batched ingest via `mempalace.miner`, OCR fallback for image PDFs, - file watcher, `export` / `import-palace` / `sync` -- **Phase 3** — Gemini importer, browser extension, JSON output mode, - packaging polish +- **Phase 2** *(current)* — `export` / `import-palace` ✅, JSON output + mode ✅, `timeline` / `entities` via MemPalace knowledge graph, batched + ingest via `mempalace.miner`, OCR fallback for image PDFs, file watcher, + `sync` +- **Phase 3** — Gemini importer, browser extension, packaging polish - **Phase 4** — `evolve` (LoRA fine-tune from your drawers), web UI ## License diff --git a/superton/cli.py b/superton/cli.py index f434e09..8b6d749 100644 --- a/superton/cli.py +++ b/superton/cli.py @@ -1,13 +1,15 @@ """SuperTon command-line interface. -Commands: +Commands (grouped as in `superton --help`): superton init set up palace + check ollama + build Superton - superton add ingest file or directory + superton add ingest file, directory, or web page superton ask "..." query Superton with palace context - superton list show recent drawers - superton search "..." semantic search with SQLite fallback + superton list show recent drawers (--json for scripts) + superton search "..." semantic search with SQLite fallback (--json) superton forget remove a drawer - superton stats palace statistics + superton stats palace statistics (--json) + superton export back up every drawer as JSON Lines + superton import-palace restore drawers from an export file superton theme [name] show / switch CLI theme superton close stop SuperTon model runners superton import pull conversations from other AI tools @@ -36,12 +38,27 @@ log = get_logger("cli") +# Help-panel names — every command is grouped so `superton --help` reads +# as a guided tour instead of an alphabetical dump. +PANEL_START = "Start here" +PANEL_INGEST = "Ingest & capture" +PANEL_EXPLORE = "Ask & explore" +PANEL_PALACE = "Palace management" +PANEL_SYSTEM = "Model & system" + app = typer.Typer( name="superton", - help="A tiny local LLM with infinite memory.", + help="A local memory palace: ingest notes, docs, and AI chats — ask with citations.", no_args_is_help=False, add_completion=False, rich_markup_mode="rich", + epilog=( + "[dim]Examples:[/dim]\n\n" + " superton add ~/notes [dim]# ingest a folder[/dim]\n\n" + ' superton ask "what did I decide about auth?"\n\n' + " superton [dim]# interactive shell[/dim]\n\n" + " superton export -o palace.jsonl [dim]# back up every drawer[/dim]" + ), ) @@ -344,7 +361,7 @@ def _build_superton(cfg: Config, *, yes: bool) -> bool: @app.callback(invoke_without_command=True) def _root( ctx: typer.Context, - version: bool = typer.Option(False, "--version", help="show version"), + version: bool = typer.Option(False, "--version", "-V", help="show version"), ) -> None: if version: ui.console().print(f"superton {__version__}") @@ -354,7 +371,7 @@ def _root( raise typer.Exit() -@app.command() +@app.command(rich_help_panel=PANEL_START) def welcome() -> None: """Show the SuperTon welcome tour at any time.""" cfg = _cfg() @@ -365,7 +382,7 @@ def welcome() -> None: ui.ready_card(cfg, stats) -@app.command() +@app.command(rich_help_panel=PANEL_START) def init( skip_model: bool = typer.Option(False, "--no-model", help="skip ollama model build"), yes: bool = typer.Option(False, "--yes", "-y", help="accept setup prompts"), @@ -605,7 +622,7 @@ def _finish_init(cfg: Config, *, offer_demo: bool = False) -> None: _offer_demo_seed(cfg, yes=False) -@app.command() +@app.command(rich_help_panel=PANEL_START) def demo() -> None: """Seed a tiny local demo palace so new users can ask immediately.""" cfg = _cfg() @@ -618,7 +635,7 @@ def demo() -> None: ui.hint('try: [bold]superton ask "what is SuperTon?"[/bold]') -@app.command() +@app.command(rich_help_panel=PANEL_INGEST) def add( target: str = typer.Argument(..., help="file, directory, or http(s) URL to ingest"), wing: str = typer.Option("default", "--wing", "-w"), @@ -646,12 +663,19 @@ def add( from superton.ingest import chunk_text drawers = 0 + deduped = 0 for chunk in chunk_text(page.markdown): mem.add(text=chunk, source=target, wing=wing, room=room) - drawers += 1 + if mem.last_insert_was_new: + drawers += 1 + else: + deduped += 1 mem.close() ui.blank() - ui.ok(f"ingested {drawers} drawers", f"from {target}") + summary = f"from {target}" + if deduped: + summary += f" · {deduped} deduped" + ui.ok(f"ingested {drawers} drawers", summary) return # File/directory branch — existing behavior. @@ -671,7 +695,7 @@ def add( ui.ok(f"ingested {total_drawers} drawers", summary) -@app.command() +@app.command(rich_help_panel=PANEL_INGEST) def note( text: str = typer.Argument(..., help="note text to capture"), tag: str | None = typer.Option(None, "--tag", "-t", help="optional note tag"), @@ -689,7 +713,7 @@ def note( ui.ok(f"captured note {drawer.id[:8]}", source) -@app.command() +@app.command(rich_help_panel=PANEL_INGEST) def pull( url: str = typer.Argument(..., help="base URL of the site to pull"), max_pages: int = typer.Option(100, "--max", "-m", help="max pages to pull"), @@ -752,7 +776,7 @@ async def _run() -> None: ui.ok(summary) -@app.command() +@app.command(rich_help_panel=PANEL_INGEST) def refresh( path: Path = typer.Argument(..., exists=True, help="file or directory to replace in memory"), wing: str = typer.Option("default", "--wing", "-w"), @@ -772,7 +796,7 @@ def refresh( ui.ok(f"refreshed {files} file(s)") -@app.command() +@app.command(rich_help_panel=PANEL_EXPLORE) def ask( question: str = typer.Argument(..., help="your question"), k: int = typer.Option(5, "--top-k", "-k"), @@ -885,14 +909,33 @@ def ask( mem.close() -@app.command("list") +def _drawer_dict(d) -> dict: + """JSON-safe dict for one drawer — shared by `list --json` and `export`.""" + return { + "id": d.id, + "text": d.text, + "source": d.source, + "wing": d.wing, + "room": d.room, + "created_at": d.created_at, + "metadata": d.metadata, + } + + +@app.command("list", rich_help_panel=PANEL_EXPLORE) def list_drawers( limit: int = typer.Option(20, "--limit", "-n"), + json_output: bool = typer.Option(False, "--json", help="emit drawers as JSON"), ) -> None: """List recent drawers.""" mem = Memory(_cfg()) rows = mem.all(limit=limit) mem.close() + if json_output: + import json + + print(json.dumps([_drawer_dict(d) for d in rows], indent=2)) + return ui.section("drawers", f"last {len(rows)}") table = ui.make_table("id", "wing/room", "source", "preview") for d in rows: @@ -906,7 +949,7 @@ def list_drawers( ui.print_table(table) -def _print_recent_sources(*, days: int, limit: int) -> None: +def _print_recent_sources(*, days: int, limit: int, json_output: bool = False) -> None: """Render the 'recent' table. Shared by `recent` and `today` so the Typer command bodies don't call each other (Option defaults are OptionInfo sentinels at function-call time, which can confuse direct @@ -915,6 +958,11 @@ def _print_recent_sources(*, days: int, limit: int) -> None: mem = Memory(_cfg()) rows = mem.recent_sources(since=since, limit=limit) mem.close() + if json_output: + import json + + print(json.dumps(rows, indent=2)) + return ui.section("recent", f"last {days} day(s)") table = ui.make_table("drawers", "latest", "source") for row in rows: @@ -925,23 +973,31 @@ def _print_recent_sources(*, days: int, limit: int) -> None: ui.print_table(table) -@app.command() +@app.command(rich_help_panel=PANEL_EXPLORE) def recent( days: int = typer.Option(7, "--days", "-d", help="look back this many days"), limit: int = typer.Option(30, "--limit", "-n"), + json_output: bool = typer.Option(False, "--json", help="emit sources as JSON"), ) -> None: """List sources added recently.""" - _print_recent_sources(days=days, limit=limit) + _print_recent_sources(days=days, limit=limit, json_output=json_output) -@app.command() -def today(limit: int = typer.Option(30, "--limit", "-n")) -> None: +@app.command(rich_help_panel=PANEL_EXPLORE) +def today( + limit: int = typer.Option(30, "--limit", "-n"), + json_output: bool = typer.Option(False, "--json", help="emit sources as JSON"), +) -> None: """List sources added in the last 24 hours.""" - _print_recent_sources(days=1, limit=limit) + _print_recent_sources(days=1, limit=limit, json_output=json_output) -@app.command() -def search(query: str, limit: int = typer.Option(10, "--limit", "-n")) -> None: +@app.command(rich_help_panel=PANEL_EXPLORE) +def search( + query: str, + limit: int = typer.Option(10, "--limit", "-n"), + json_output: bool = typer.Option(False, "--json", help="emit hits as JSON"), +) -> None: """Semantic search across drawers with SQLite fallback.""" mem = Memory(_cfg()) with ui.spinner( @@ -949,6 +1005,14 @@ def search(query: str, limit: int = typer.Option(10, "--limit", "-n")) -> None: phases=["Embedding query", "Scanning drawers", "Re-ranking hits"], ): hits = mem.search(query, limit=limit) + if json_output: + import json + + mem.close() + print(json.dumps( + [{"score": h.score, **_drawer_dict(h.drawer)} for h in hits], indent=2 + )) + return if not hits: ui.shimmer(f" scanning palace for {query!r}…") ui.warn("no drawers matched") @@ -962,7 +1026,7 @@ def search(query: str, limit: int = typer.Option(10, "--limit", "-n")) -> None: mem.close() -@app.command() +@app.command(rich_help_panel=PANEL_PALACE) def forget(drawer_id: str) -> None: """Remove a drawer by ID.""" mem = Memory(_cfg()) @@ -979,7 +1043,7 @@ def forget(drawer_id: str) -> None: ui.warn(f"no drawer matched {drawer_id}") -@app.command("forget-source") +@app.command("forget-source", rich_help_panel=PANEL_PALACE) def forget_source(source: str) -> None: """Remove every drawer from a source path or filename.""" mem = Memory(_cfg()) @@ -991,15 +1055,21 @@ def forget_source(source: str) -> None: ui.warn(f"no source matched {source}") -@app.command() +@app.command(rich_help_panel=PANEL_EXPLORE) def sources( limit: int = typer.Option(30, "--limit", "-n"), health: bool = typer.Option(False, "--health", help="show source refresh/index health"), + json_output: bool = typer.Option(False, "--json", help="emit sources as JSON"), ) -> None: """List indexed source files.""" mem = Memory(_cfg()) rows = mem.source_health(limit=limit) if health else mem.sources(limit=limit) mem.close() + if json_output: + import json + + print(json.dumps(rows, indent=2)) + return ui.section("sources", f"{len(rows)} indexed") if health: table = ui.make_table("drawers", "indexed", "pending", "path", "source") @@ -1018,7 +1088,7 @@ def sources( ui.print_table(table) -@app.command("model") +@app.command("model", rich_help_panel=PANEL_SYSTEM) def model_info() -> None: """Show the Superton model configuration.""" cfg = _cfg() @@ -1030,7 +1100,7 @@ def model_info() -> None: ]) -@app.command("theme") +@app.command("theme", rich_help_panel=PANEL_SYSTEM) def theme_cmd( name: str | None = typer.Argument(None, help=f"one of: {', '.join(ui.THEMES)}"), ) -> None: @@ -1071,12 +1141,19 @@ def theme_cmd( ui.ok(f"theme → {name}", ui.theme().label) -@app.command() -def stats() -> None: +@app.command(rich_help_panel=PANEL_PALACE) +def stats( + json_output: bool = typer.Option(False, "--json", help="emit machine-readable stats"), +) -> None: """Palace statistics.""" mem = Memory(_cfg()) s = mem.stats() mem.close() + if json_output: + import json + + print(json.dumps(s, indent=2, sort_keys=True)) + return ui.section("palace") ui.kv([ ("drawers", str(s["drawers"])), @@ -1089,7 +1166,7 @@ def stats() -> None: ui.warn("semantic fallback active", str(s["semantic_error"])) -@app.command() +@app.command(rich_help_panel=PANEL_SYSTEM) def doctor( json_output: bool = typer.Option(False, "--json", help="emit machine-readable diagnostics"), ) -> None: @@ -1099,7 +1176,7 @@ def doctor( render_doctor_report(_cfg(), json_output=json_output) -@app.command() +@app.command(rich_help_panel=PANEL_PALACE) def reindex() -> None: """Rebuild semantic index from the SQLite drawer store.""" mem = Memory(_cfg()) @@ -1116,7 +1193,89 @@ def reindex() -> None: ui.ok(f"reindexed {total} drawers") -@app.command("close") +@app.command(rich_help_panel=PANEL_PALACE) +def export( + out: Path | None = typer.Option( + None, "--out", "-o", help="write to this file (default: stdout)" + ), + limit: int = typer.Option(0, "--limit", "-n", help="max drawers to export (0 = all)"), +) -> None: + """Export every drawer as JSON Lines — for backup, sync, or migration. + + Each line is one self-contained JSON object (id, text, source, wing, + room, created_at, metadata). Round-trips through + `superton import-palace ` on any machine; drawer ids are + content-addressed so re-importing is idempotent. + """ + import json + + mem = Memory(_cfg()) + drawers = mem.all(limit=limit if limit > 0 else 10_000_000) + mem.close() + lines = (json.dumps(_drawer_dict(d), ensure_ascii=False) for d in drawers) + if out is None: + for line in lines: + print(line) + return + path = out.expanduser() + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as fh: + for line in lines: + fh.write(line + "\n") + ui.ok(f"exported {len(drawers)} drawers", str(path)) + + +@app.command("import-palace", rich_help_panel=PANEL_PALACE) +def import_palace( + file: Path = typer.Argument(..., exists=True, help="JSONL file from `superton export`"), +) -> None: + """Import drawers from a `superton export` JSON Lines file. + + Content-addressed ids make this idempotent: drawers that already + exist are counted as deduped, not duplicated. + """ + import json + + mem = Memory(_cfg()) + added = 0 + deduped = 0 + skipped = 0 + with ( + ui.progress("importing palace") as advance, + file.expanduser().open("r", encoding="utf-8") as fh, + ): + for lineno, raw in enumerate(fh, 1): + line = raw.strip() + if not line: + continue + try: + row = json.loads(line) + text = row["text"] + source = row["source"] + except (json.JSONDecodeError, TypeError, KeyError): + skipped += 1 + log.warning("import-palace: skipping malformed line %d", lineno) + continue + mem.add( + text=text, + source=source, + wing=row.get("wing") or "default", + room=row.get("room") or "default", + metadata=row.get("metadata") or {}, + ) + if mem.last_insert_was_new: + added += 1 + else: + deduped += 1 + advance(description=f"importing palace [dim]{added} drawers[/]") + mem.close() + detail = f"{deduped} already present" if deduped else None + ui.ok(f"imported {added} drawers", detail) + if skipped: + ui.warn(f"skipped {skipped} malformed line(s)", "see SUPERTON_LOG=warn for line numbers") + + +@app.command("close", rich_help_panel=PANEL_SYSTEM) def close_models( all_models: bool = typer.Option( False, @@ -1262,7 +1421,7 @@ def _uninstall_model_names(cfg: Config, *, models: bool, all_models: bool) -> li return list(dict.fromkeys(names)) -@app.command() +@app.command(rich_help_panel=PANEL_SYSTEM) def uninstall( yes: bool = typer.Option(False, "--yes", "-y", help="confirm removal prompts"), data: bool = typer.Option(True, "--data/--keep-data", help="remove the local palace and config"), @@ -1425,7 +1584,7 @@ def uninstall( import_app = typer.Typer(help="Import conversations from other AI tools.") -app.add_typer(import_app, name="import") +app.add_typer(import_app, name="import", rich_help_panel=PANEL_INGEST) _REPLACE_HELP = ( @@ -1509,7 +1668,7 @@ def import_amp( ui.ok(f"imported {drawers} drawers", f"from {files} Amp files") -@app.command() +@app.command(rich_help_panel=PANEL_SYSTEM) def tune() -> None: """Open the Modelfile in $EDITOR and rebuild Superton.""" cfg = _cfg() @@ -1530,7 +1689,7 @@ def tune() -> None: # --- MemPalace power-user commands -------------------------------------------- mcp_app = typer.Typer(help="Expose the palace over MCP for Claude / Cursor / Gemini.") -app.add_typer(mcp_app, name="mcp") +app.add_typer(mcp_app, name="mcp", rich_help_panel=PANEL_SYSTEM) @mcp_app.command("serve") @@ -1580,7 +1739,7 @@ def mcp_serve( _sys.argv = argv_backup -@app.command() +@app.command(rich_help_panel=PANEL_PALACE) def dedup( threshold: float = typer.Option( 0.92, "--threshold", "-t", help="similarity threshold (0-1, higher = stricter)" diff --git a/superton/config.py b/superton/config.py index 914a15a..f88b374 100644 --- a/superton/config.py +++ b/superton/config.py @@ -110,8 +110,8 @@ def load(cls) -> Config: theme = os.environ.get("SUPERTON_THEME", settings.get("theme", "ember")) # ui.THEMES is the source of truth, but importing ui here would create - # a load-order cycle. Whitelist the four shipped themes inline. - if theme not in {"ember", "crimson", "void", "ash"}: + # a load-order cycle. Whitelist the shipped themes inline. + if theme not in {"ember", "crimson", "void", "ash", "aurora"}: log.warning("unknown theme=%r, falling back to 'ember'", theme) theme = "ember" diff --git a/superton/doctor.py b/superton/doctor.py index 0121c73..57d67cb 100644 --- a/superton/doctor.py +++ b/superton/doctor.py @@ -12,7 +12,6 @@ import platform import shutil import sys -from pathlib import Path from typing import Any from superton import __version__, ui @@ -31,11 +30,16 @@ def _mask_token(token: str) -> str: def _detect_install_method() -> str: - exe = str(Path(sys.executable).resolve()) - if "/uv/tools/" in exe or "uv\\tools\\" in exe: - return "uv" - if "/pipx/" in exe or "pipx\\" in exe: - return "pipx" + """Mirror cli._detect_install_method: check `sys.prefix` plus the *raw* + `sys.executable`. Resolving the executable's symlink walks out of uv's + tool venv (bin/python links to the system interpreter) and misreports + uv installs as pip.""" + for raw in (sys.prefix, sys.executable): + text = str(raw) + if "/uv/tools/" in text or "uv\\tools\\" in text: + return "uv" + if "/pipx/" in text or "pipx\\" in text: + return "pipx" return "pip" diff --git a/superton/errors.py b/superton/errors.py index fd3769f..4145c57 100644 --- a/superton/errors.py +++ b/superton/errors.py @@ -72,7 +72,7 @@ def hint_for(exc: BaseException) -> _Hint: if isinstance(exc, ConfigError): return _Hint( summary=str(exc) or "bad configuration", - hint="edit [bold]~/.superton/config.toml[/bold] or unset the bad env var", + hint="run [bold]superton doctor[/bold] to find config.toml, then fix it or unset the bad env var", ) if isinstance(exc, MemoryError): return _Hint( diff --git a/superton/shell.py b/superton/shell.py index 7e0d8e2..f019b8a 100644 --- a/superton/shell.py +++ b/superton/shell.py @@ -40,12 +40,16 @@ STOPWORDS = chat.STOPWORDS COMMAND_HELP = { "/add": "ingest a file or directory", + "/clear": "reset the conversation", "/doctor": "show runtime health", "/forget-source": "remove all drawers from a source", "/help": "show shortcuts", "/import": "pull conversations from another AI tool", + "/list": "show recent drawers", "/model": "show model configuration", + "/note": "capture a quick note", "/quit": "exit SuperTon", + "/recent": "show recently added sources", "/refresh": "reingest a source and remove stale chunks", "/reindex": "rebuild semantic index", "/search": "search memory", @@ -53,9 +57,99 @@ "/stats": "show palace stats", "/stop": "stop Superton without quitting", "/theme": "show/switch CLI theme", + "/why": "toggle the retrieval trace under answers", } -ANSWER_CONTEXT_DRAWERS = 10 -ANSWER_DRAWER_CHARS = 1200 + +# Grouped view of COMMAND_HELP used by /help so the cheatsheet reads as a +# small map of the shell instead of one long line. +COMMAND_GROUPS: list[tuple[str, list[tuple[str, str]]]] = [ + ("chat", [ + ("/why", "toggle retrieval trace under answers"), + ("/clear", "reset the conversation"), + ("/stop", "stop Superton without quitting"), + ("/quit", "exit SuperTon (^D works too)"), + ]), + ("ingest", [ + ("/add ", "ingest a file or directory"), + ("/note ", "capture a quick note"), + ("/import ", "claude-code · chatgpt · cursor · amp"), + ("/refresh ", "reingest a source, drop stale chunks"), + ]), + ("explore", [ + ("/search ", "search memory"), + ("/list", "show recent drawers"), + ("/recent", "show recently added sources"), + ("/sources", "list indexed sources"), + ("/stats", "show palace stats"), + ]), + ("system", [ + ("/theme [name]", "show or switch CLI theme"), + ("/model", "show model configuration"), + ("/doctor", "show runtime health"), + ("/reindex", "rebuild semantic index"), + ("/forget-source ", "remove all drawers from a source"), + ]), +] + +# Every spelling the REPL accepts as a command — used to catch typos before +# they fall through to the model as a chat message. +KNOWN_COMMANDS = set(COMMAND_HELP) | {"/exit", "/quit", "/help", "/clear"} + +# Usage lines for commands that require an argument, shown when the user +# types the bare command. +COMMAND_USAGE = { + "/add": "/add ", + "/forget-source": "/forget-source ", + "/import": "/import claude-code | chatgpt | cursor | amp", + "/note": "/note ", + "/refresh": "/refresh ", + "/search": "/search ", +} + + +def _suggest_command(text: str) -> str | None: + """Closest known slash command for a mistyped one, or None. + + Only the command head (first whitespace-separated token) is matched, + so `/serach foo bar` still suggests `/search`. + """ + import difflib + + head = text.split()[0] if text.split() else text + matches = difflib.get_close_matches(head, sorted(KNOWN_COMMANDS), n=1, cutoff=0.6) + return matches[0] if matches else None + + +def _path_completion_candidates(fragment: str, *, limit: int = 30) -> list[tuple[str, str]]: + """Filesystem completions for a partial path typed after /add or /refresh. + + Returns `(insert_text, display)` pairs; `insert_text` replaces the + final path component the user is typing. Directories get a trailing + slash so completion can be chained. Hidden entries only show up once + the fragment's basename itself starts with a dot. + """ + raw = fragment.strip() + expanded = Path(raw).expanduser() if raw else Path(".") + if raw.endswith(("/", "\\")) or raw == "": + base, prefix = (expanded if raw else Path.cwd()), "" + else: + base, prefix = expanded.parent, expanded.name + try: + entries = sorted(base.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())) + except OSError: + return [] + out: list[tuple[str, str]] = [] + for entry in entries: + name = entry.name + if not name.startswith(prefix): + continue + if name.startswith(".") and not prefix.startswith("."): + continue + display = name + "/" if entry.is_dir() else name + out.append((display, display)) + if len(out) >= limit: + break + return out class _Status: @@ -142,6 +236,17 @@ def get_completions(self, document, complete_event): display_meta=theme_obj.label, ) return + if parts and parts[0] in {"/add", "/refresh"} and " " in text: + # Live filesystem completion for the path argument. + fragment = text.split(" ", 1)[1] + tail = fragment.rsplit("/", 1)[-1] + for insert, display in _path_completion_candidates(fragment): + yield Completion( + insert, + start_position=-len(tail), + display=display, + ) + return if " " in text: return for command, help_text in COMMAND_HELP.items(): @@ -435,6 +540,91 @@ def _print_themes(cfg: Config) -> None: ui.blank() +def _print_help() -> None: + """Grouped, themed cheatsheet for `/help` / `?`. + + One compact table per command group — mirrors the grouped panels in + `superton --help` so both surfaces teach the same map. + """ + from rich.table import Table + from rich.text import Text + + t = ui.theme() + table = Table(show_header=False, box=None, pad_edge=False, padding=(0, 2, 0, 0)) + table.add_column(no_wrap=True) + table.add_column(no_wrap=True) + table.add_column() + for gi, (group, commands) in enumerate(COMMAND_GROUPS): + if gi: + table.add_row("", "", "") + for ci, (cmd, desc) in enumerate(commands): + head, _, arg = cmd.partition(" ") + cmd_text = Text() + cmd_text.append(head, style=f"bold {t.primary}") + if arg: + cmd_text.append(f" {arg}", style=t.secondary) + table.add_row( + Text(group, style=t.muted) if ci == 0 else "", + cmd_text, + Text(desc, style=t.muted), + ) + ui.blank() + ui.print_table(table) + ui.blank() + ui.hint("paste any file path to ingest it · just type to chat") + ui.blank() + + +def _print_drawers(mem: Memory, *, limit: int = 10) -> None: + """Recent drawers — shell twin of `superton list`.""" + rows = mem.all(limit=limit) + ui.blank() + if not rows: + ui.hint("no drawers yet — /add a file or paste a path") + ui.blank() + return + table = ui.make_table("id", "wing/room", "source", "preview") + for d in rows: + preview = d.text.replace("\n", " ")[:70] + table.add_row( + ui.style_id(d.id[:8]), + f"{d.wing}/{d.room}", + ui.style_path(Path(d.source).name), + preview, + ) + ui.print_table(table) + ui.blank() + + +def _print_recent(mem: Memory, *, days: int = 7, limit: int = 15) -> None: + """Recently added sources — shell twin of `superton recent`.""" + import time + + rows = mem.recent_sources(since=time.time() - days * 86400, limit=limit) + ui.blank() + if not rows: + ui.hint(f"nothing added in the last {days} day(s)") + ui.blank() + return + table = ui.make_table("drawers", "latest", "source") + for row in rows: + latest = time.strftime("%Y-%m-%d %H:%M", time.localtime(float(row["latest"]))) + table.add_row(str(row["drawers"]), latest, row["source"]) + ui.print_table(table) + ui.blank() + + +def _capture_note(mem: Memory, text: str) -> None: + """Quick note capture — shell twin of `superton note`.""" + import time + + source = f"note:{time.strftime('%Y%m%d-%H%M%S')}" + drawer = mem.add(text=text, source=source, wing="notes", room="daily", metadata={"kind": "note"}) + ui.blank() + ui.ok(f"captured note {drawer.id[:8]}", source) + ui.blank() + + def _run_import(mem: Memory, spec: str) -> None: """Handle `/import [path]` from inside the shell. @@ -542,12 +732,16 @@ def _answer( model: Model, question: str, history: list[tuple[str, str]] | None = None, + *, + show_why: bool = False, ) -> str: """Render a chat turn through the shell's Rich console. The retrieval / refusal / prompt-building logic lives in `superton.chat`. This function is the *display* half: it takes the planned answer, streams tokens through `ui.stream_answer`, and prints citations. + With `show_why=True` (the `/why` toggle) the retrieval trace is + rendered before the answer streams. """ # Retrieval can take a few hundred ms on large palaces — show a # spinner so the pause between Enter and the Superton header doesn't @@ -558,6 +752,8 @@ def _answer( if plan.refusal is not None: _print_assistant(plan.refusal) return plan.refusal + if show_why and plan.hits: + _print_search_hits(plan.hits[:5]) try: answer = ui.stream_answer(chat.stream_answer(model, plan)) @@ -585,6 +781,7 @@ def run() -> None: model = Model(cfg) status = _Status(cfg, mem, model) history: list[tuple[str, str]] = [] + show_why = False try: _print_intro(cfg, mem) while True: @@ -599,13 +796,32 @@ def run() -> None: _stop_active_model(model, cfg, quiet=True) break if text in {"/help", "?"}: - ui.console().print( - "ingest: /add · /import claude-code|chatgpt|cursor|amp · " - "/refresh \n" - "search: /search · /sources · /forget-source \n" - "config: /model · /theme · /reindex\n" - "system: /doctor · /stats · /clear · /stop · /quit" - ) + _print_help() + continue + if text == "/why": + show_why = not show_why + ui.blank() + if show_why: + ui.ok("retrieval trace on", "each answer will show the drawers it drew from") + else: + ui.ok("retrieval trace off") + ui.blank() + continue + if text == "/list": + _print_drawers(mem) + continue + if text == "/recent": + _print_recent(mem) + continue + if text == "/note" or text.startswith("/note "): + body = text.removeprefix("/note").strip() + if not body: + ui.blank() + ui.hint("usage: /note ") + ui.blank() + continue + _capture_note(mem, body) + status.refresh(cfg) continue if text == "/stop": _stop_active_model(model, cfg) @@ -738,7 +954,25 @@ def run() -> None: ui.ok(f"ingested {drawers} drawers", suffix) ui.blank() continue - _answer_text = _answer(mem, model, text, history=history) + if text.startswith("/"): + # A slash prefix means the user wanted a command, not a chat + # turn — never forward typos to the model. Point at the + # missing argument or suggest the closest known command. + head = text.split()[0] + ui.blank() + if head in KNOWN_COMMANDS: + ui.warn(f"{head} needs an argument") + ui.hint(f"usage: [bold]{COMMAND_USAGE.get(head, head + ' ')}[/bold]") + else: + suggestion = _suggest_command(text) + if suggestion: + ui.warn(f"unknown command: {head}", f"did you mean {suggestion}?") + else: + ui.warn(f"unknown command: {head}") + ui.hint("type [bold]/help[/bold] to see every command") + ui.blank() + continue + _answer_text = _answer(mem, model, text, history=history, show_why=show_why) history.append(("user", text)) history.append(("assistant", _answer_text)) # Bound the ring buffer. diff --git a/superton/ui.py b/superton/ui.py index 4189f0d..a4b0d80 100644 --- a/superton/ui.py +++ b/superton/ui.py @@ -73,7 +73,7 @@ class Theme: tempo: float = 1.0 -# Four hand-tuned themes. Colors are hex where we want fine control and +# Five hand-tuned themes. Colors are hex where we want fine control and # named rich colors (e.g. "grey50") where terminal remapping is desirable. THEMES: dict[str, Theme] = { "ember": Theme( @@ -156,6 +156,26 @@ class Theme: cursor="_", tempo=1.0, ), + "aurora": Theme( + name="aurora", + label="teal on black · northern lights", + primary="#2DD4BF", + secondary="#99F6E4", + muted="#4F7A75", + success="#5EEAD4", + warning="#FBBF24", + error="#FF4D6D", + info="#99F6E4", + neutral="#E9FFFB", + rule="#134E4A", + prompt="#2DD4BF", + prompt_glyph="❖", + bullet="›", + spinner="dots2", + rule_char="┄", + cursor="▍", + tempo=0.95, + ), } DEFAULT_THEME = "ember" @@ -178,8 +198,14 @@ def _resolve_theme_name() -> str: return DEFAULT_THEME -_console = Console() -_err_console = Console(stderr=True) +# Honor the NO_COLOR convention (https://no-color.org) plus a scoped +# SUPERTON_NO_COLOR override. Rich already degrades on non-TTY output; +# this covers users who want a colorless TTY (screen readers, logs, +# high-contrast terminal palettes). +_NO_COLOR = bool(os.environ.get("NO_COLOR") or os.environ.get("SUPERTON_NO_COLOR")) + +_console = Console(no_color=_NO_COLOR or None) +_err_console = Console(stderr=True, no_color=_NO_COLOR or None) _current: Theme = THEMES[_resolve_theme_name()] # Install-flow palette — fire-toned signal vocabulary that stays readable diff --git a/tests/test_cli.py b/tests/test_cli.py index 9a896fd..dcd5dc4 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -422,3 +422,119 @@ def test_init_preflight_mentions_semantic_not_ollama_embed(env: Path): result = CliRunner().invoke(app, ["init", "--no-model", "-y"]) assert result.exit_code == 0 assert "nomic-embed-text" not in result.stdout + + +# --- grouped help, JSON output, export / import-palace ------------------------- + + +def test_version_short_flag(env: Path): + result = CliRunner().invoke(app, ["-V"]) + assert result.exit_code == 0 + assert "superton" in result.stdout.lower() + + +def test_help_shows_grouped_panels(env: Path): + result = CliRunner().invoke(app, ["--help"]) + assert result.exit_code == 0 + for panel in ("Start here", "Ingest & capture", "Ask & explore", "Palace management"): + assert panel in result.stdout + + +def test_stats_json_emits_machine_readable(env: Path): + result = CliRunner().invoke(app, ["stats", "--json"]) + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert "drawers" in payload and "backend" in payload + + +def test_list_json_round_trips_drawer_fields(env: Path): + CliRunner().invoke(app, ["note", "json list check"]) + result = CliRunner().invoke(app, ["list", "--json"]) + assert result.exit_code == 0 + rows = json.loads(result.stdout) + assert rows and {"id", "text", "source", "wing", "room"} <= set(rows[0]) + + +def test_search_json_includes_scores(env: Path): + CliRunner().invoke(app, ["note", "token bucket rate limiting"]) + result = CliRunner().invoke(app, ["search", "token bucket", "--json"]) + assert result.exit_code == 0 + rows = json.loads(result.stdout) + assert rows and "score" in rows[0] and "text" in rows[0] + + +def test_sources_json(env: Path): + CliRunner().invoke(app, ["note", "sources json check"]) + result = CliRunner().invoke(app, ["sources", "--json"]) + assert result.exit_code == 0 + rows = json.loads(result.stdout) + assert rows and "source" in rows[0] and "drawers" in rows[0] + + +def test_recent_json(env: Path): + CliRunner().invoke(app, ["note", "recent json check"]) + result = CliRunner().invoke(app, ["recent", "--json"]) + assert result.exit_code == 0 + rows = json.loads(result.stdout) + assert rows and rows[0]["drawers"] >= 1 + + +def test_export_stdout_is_jsonl(env: Path): + CliRunner().invoke(app, ["note", "export me"]) + result = CliRunner().invoke(app, ["export"]) + assert result.exit_code == 0 + lines = [ln for ln in result.stdout.splitlines() if ln.strip()] + assert lines + row = json.loads(lines[0]) + assert {"id", "text", "source", "wing", "room", "created_at", "metadata"} <= set(row) + + +def test_export_import_palace_round_trip(env: Path, tmp_path: Path): + CliRunner().invoke(app, ["note", "round trip drawer one"]) + CliRunner().invoke(app, ["note", "round trip drawer two"]) + out = tmp_path / "backup" / "palace.jsonl" + result = CliRunner().invoke(app, ["export", "-o", str(out)]) + assert result.exit_code == 0 + assert out.exists() + + # Re-import into the same palace: content-addressed ids dedupe everything. + result = CliRunner().invoke(app, ["import-palace", str(out)]) + assert result.exit_code == 0 + assert "imported 0 drawers" in result.stdout + assert "already present" in result.stdout + + +def test_import_palace_fresh_home_restores_drawers( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.setenv("SUPERTON_MEMORY_BACKEND", "sqlite") + home_a = tmp_path / "home-a" + home_b = tmp_path / "home-b" + backup = tmp_path / "palace.jsonl" + + monkeypatch.setenv("SUPERTON_HOME", str(home_a)) + CliRunner().invoke(app, ["note", "migrate this drawer"]) + assert CliRunner().invoke(app, ["export", "-o", str(backup)]).exit_code == 0 + + monkeypatch.setenv("SUPERTON_HOME", str(home_b)) + result = CliRunner().invoke(app, ["import-palace", str(backup)]) + assert result.exit_code == 0 + assert "imported 1 drawers" in result.stdout + + listed = CliRunner().invoke(app, ["list", "--json"]) + rows = json.loads(listed.stdout) + assert any("migrate this drawer" in r["text"] for r in rows) + + +def test_import_palace_skips_malformed_lines(env: Path, tmp_path: Path): + bad = tmp_path / "bad.jsonl" + bad.write_text( + '{"text": "good drawer", "source": "backup.md"}\n' + "not json at all\n" + '{"missing": "text and source"}\n', + encoding="utf-8", + ) + result = CliRunner().invoke(app, ["import-palace", str(bad)]) + assert result.exit_code == 0 + assert "imported 1 drawers" in result.stdout + assert "skipped 2 malformed" in result.stdout diff --git a/tests/test_shell_helpers.py b/tests/test_shell_helpers.py index 8e85215..3740096 100644 --- a/tests/test_shell_helpers.py +++ b/tests/test_shell_helpers.py @@ -185,3 +185,73 @@ def test_path_from_input_returns_none_for_question_text(): def test_path_from_input_returns_none_for_nonexistent_path(): assert _path_from_input("/definitely/not/a/real/path/anywhere.xyz") is None + + +# --- slash-command typo suggestions + path completion --------------------------- + + +def test_suggest_command_fixes_close_typo(): + from superton.shell import _suggest_command + + assert _suggest_command("/serach auth") == "/search" + assert _suggest_command("/them") == "/theme" + assert _suggest_command("/impotr claude-code") == "/import" + + +def test_suggest_command_returns_none_for_garbage(): + from superton.shell import _suggest_command + + assert _suggest_command("/zzzqqq") is None + + +def test_known_commands_cover_command_help(): + from superton.shell import COMMAND_HELP, KNOWN_COMMANDS + + assert set(COMMAND_HELP) <= KNOWN_COMMANDS + + +def test_command_groups_only_reference_known_commands(): + from superton.shell import COMMAND_GROUPS, KNOWN_COMMANDS + + heads = { + cmd.split()[0] + for _, commands in COMMAND_GROUPS + for cmd, _ in commands + } + assert heads <= KNOWN_COMMANDS + + +def test_path_completion_lists_directory(tmp_path): + from superton.shell import _path_completion_candidates + + (tmp_path / "docs").mkdir() + (tmp_path / "readme.md").write_text("x") + completions = dict(_path_completion_candidates(f"{tmp_path}/")) + assert "docs/" in completions + assert "readme.md" in completions + + +def test_path_completion_filters_by_prefix(tmp_path): + from superton.shell import _path_completion_candidates + + (tmp_path / "notes.md").write_text("x") + (tmp_path / "other.md").write_text("x") + names = [text for text, _ in _path_completion_candidates(f"{tmp_path}/no")] + assert names == ["notes.md"] + + +def test_path_completion_hides_dotfiles_unless_asked(tmp_path): + from superton.shell import _path_completion_candidates + + (tmp_path / ".secret").write_text("x") + (tmp_path / "visible.md").write_text("x") + plain = [text for text, _ in _path_completion_candidates(f"{tmp_path}/")] + assert ".secret" not in plain + dotted = [text for text, _ in _path_completion_candidates(f"{tmp_path}/.se")] + assert ".secret" in dotted + + +def test_path_completion_bad_directory_is_empty(): + from superton.shell import _path_completion_candidates + + assert _path_completion_candidates("/definitely/not/here/") == [] diff --git a/tests/test_ui_primitives.py b/tests/test_ui_primitives.py index 5200874..1b125b5 100644 --- a/tests/test_ui_primitives.py +++ b/tests/test_ui_primitives.py @@ -181,3 +181,53 @@ def test_stream_answer_non_tty_has_no_cursor(capsys): answer = ui.stream_answer(iter(["hello ", "world"])) assert answer == "hello world" assert ui.theme().cursor not in capsys.readouterr().out + + +# --- NO_COLOR / SUPERTON_NO_COLOR ---------------------------------------------- + + +def test_superton_no_color_strips_ansi(tmp_path): + """SUPERTON_NO_COLOR must suppress ANSI even when color is forced on. + + Run in a subprocess because the ui module resolves the flag at import + time. FORCE_COLOR makes Rich emit ANSI into the pipe for the control + run, proving the assertion is meaningful. + """ + import os + import subprocess + import sys + + code = "from superton import ui; ui.console().print('[red]colorful[/red]')" + base_env = { + **os.environ, + "SUPERTON_HOME": str(tmp_path), + "FORCE_COLOR": "1", + "COLUMNS": "80", + } + base_env.pop("NO_COLOR", None) + base_env.pop("SUPERTON_NO_COLOR", None) + + with_color = subprocess.run( + [sys.executable, "-c", code], capture_output=True, text=True, env=base_env, timeout=60 + ) + assert "\x1b[" in with_color.stdout + + no_color = subprocess.run( + [sys.executable, "-c", code], + capture_output=True, + text=True, + env={**base_env, "SUPERTON_NO_COLOR": "1"}, + timeout=60, + ) + assert "colorful" in no_color.stdout + assert "\x1b[" not in no_color.stdout + + +def test_aurora_theme_registered(): + assert "aurora" in ui.THEMES + t = ui.THEMES["aurora"] + assert t.prompt_glyph and t.label + # config.Config.load's inline whitelist must accept every shipped theme. + from superton.config import Config # noqa: F401 — import guards the cycle note + + assert {"ember", "crimson", "void", "ash", "aurora"} == set(ui.THEMES) diff --git a/uv.lock b/uv.lock index 9acf3cb..651e856 100644 --- a/uv.lock +++ b/uv.lock @@ -2419,7 +2419,7 @@ wheels = [ [[package]] name = "superton" -version = "0.3.0b1" +version = "0.4.0b1" source = { editable = "." } dependencies = [ { name = "beautifulsoup4" }, @@ -2459,7 +2459,7 @@ requires-dist = [ { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10.0" }, { name = "platformdirs", specifier = ">=4.2.0" }, { name = "playwright", marker = "extra == 'web'", specifier = ">=1.40.0" }, - { name = "prompt-toolkit", specifier = ">=3.0.0" }, + { name = "prompt-toolkit", specifier = ">=3.0.19" }, { name = "pydantic", specifier = ">=2.7.0" }, { name = "pypdf", specifier = ">=4.2.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },