diff --git a/README.md b/README.md index b8b5e3fa..ed72821c 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,12 @@ -Semble is a code search library built for agents. It returns the exact code snippets they need instantly, using ~99% fewer tokens than grep+read. Indexing and searching a full codebase end-to-end takes under a second, matching the retrieval quality of a code-specialized transformer while indexing ~340x faster and querying ~17x faster (see [benchmarks](#benchmarks)). Everything runs on CPU with no API keys, GPU, or external services. Use it as an MCP server, a CLI tool via AGENTS.md, or a dedicated sub-agent, and any coding agent (Claude Code, Cursor, Codex, OpenCode, etc.) gets instant access to any repo. +Semble is a code search library built for agents. It returns the exact code snippets they need instantly, using ~99% fewer tokens than grep+read. Indexing and searching a full codebase end-to-end takes under a second for most repos, matching the retrieval quality of a code-specialized transformer while indexing ~340x faster and querying ~17x faster (see [benchmarks](#benchmarks)). Everything runs on CPU with no API keys, GPU, or external services. Use it as an MCP server, a CLI tool via AGENTS.md, or a dedicated sub-agent, and any coding agent (Claude Code, Cursor, Codex, OpenCode, etc.) gets instant access to any repo. + + + + semble install detecting Claude Code, Cursor, and Codex, then semble search returning ranked code snippets from pydantic + ## Quickstart diff --git a/assets/images/demo-dark.gif b/assets/images/demo-dark.gif new file mode 100644 index 00000000..c9227f70 Binary files /dev/null and b/assets/images/demo-dark.gif differ diff --git a/assets/images/demo-light.gif b/assets/images/demo-light.gif new file mode 100644 index 00000000..3688c475 Binary files /dev/null and b/assets/images/demo-light.gif differ diff --git a/scripts/demo/dark.tape b/scripts/demo/dark.tape new file mode 100644 index 00000000..c835a918 --- /dev/null +++ b/scripts/demo/dark.tape @@ -0,0 +1,48 @@ +Output "assets/images/demo-dark.gif" + +Set Shell "bash" +Set FontSize 18 +Set Width 1200 +Set Height 672 +Set Theme "Catppuccin Mocha" +Set Padding 20 +Set Margin 30 +Set MarginFill "#11111b" +Set BorderRadius 12 +Set WindowBar Colorful +Set WindowBarSize 36 +Set TypingSpeed 45ms + +Hide +Type "exec env -i HOME=$DEMO_HOME PATH=$SEMBLE_BIN_DIR:/usr/bin:/bin TERM=xterm-256color /bin/bash --norc --noprofile" +Enter +Sleep 800ms +Type "cd $HOME; PS1='$ '; clear" +Enter +Sleep 300ms +Show + +Type "semble install" +Enter +Sleep 1200ms +Enter +Sleep 500ms +Enter +Sleep 500ms +Enter +Sleep 2200ms + +Hide +Type "clear" +Enter +Sleep 300ms +Show + +Type "cd pydantic" +Enter +Sleep 600ms + +Type@25ms `semble search "Where is snake_case converted to camelCase?" -k 1 --format text` +Enter +Wait@60s /\$\s*$/ +Sleep 3500ms diff --git a/scripts/demo/light.tape b/scripts/demo/light.tape new file mode 100644 index 00000000..8d61ddad --- /dev/null +++ b/scripts/demo/light.tape @@ -0,0 +1,48 @@ +Output "assets/images/demo-light.gif" + +Set Shell "bash" +Set FontSize 18 +Set Width 1200 +Set Height 672 +Set Theme "Catppuccin Latte" +Set Padding 20 +Set Margin 30 +Set MarginFill "#eff1f5" +Set BorderRadius 12 +Set WindowBar Colorful +Set WindowBarSize 36 +Set TypingSpeed 45ms + +Hide +Type "exec env -i HOME=$DEMO_HOME PATH=$SEMBLE_BIN_DIR:/usr/bin:/bin TERM=xterm-256color /bin/bash --norc --noprofile" +Enter +Sleep 800ms +Type "cd $HOME; PS1='$ '; clear" +Enter +Sleep 300ms +Show + +Type "semble install" +Enter +Sleep 1200ms +Enter +Sleep 500ms +Enter +Sleep 500ms +Enter +Sleep 2200ms + +Hide +Type "clear" +Enter +Sleep 300ms +Show + +Type "cd pydantic" +Enter +Sleep 600ms + +Type@25ms `semble search "Where is snake_case converted to camelCase?" -k 1 --format text` +Enter +Wait@60s /\$\s*$/ +Sleep 3500ms diff --git a/scripts/demo/record.sh b/scripts/demo/record.sh new file mode 100755 index 00000000..9c662806 --- /dev/null +++ b/scripts/demo/record.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Records the README demo GIFs (assets/images/demo-{dark,light}.gif) from dark.tape and light.tape. +set -euo pipefail + +cd "$(dirname "$0")/../.." +command -v vhs >/dev/null || { echo "vhs not found, install it with: brew install vhs" >&2; exit 1; } +[ -x .venv/bin/semble ] || { echo ".venv/bin/semble not found, run: make install" >&2; exit 1; } +export SEMBLE_BIN_DIR="$PWD/.venv/bin" + +seed=$(mktemp -d) +dark=$(mktemp -d) +light=$(mktemp -d) +trap 'rm -rf "$seed" "$dark" "$light"' EXIT + +# Create an isolated $HOME with agent config dirs so `semble install` detects some agents. +mkdir -p "$seed"/.claude "$seed"/.cursor "$seed"/.codex +git -c advice.detachedHead=false clone -q --depth 1 --branch v2.13.5 https://github.com/pydantic/pydantic "$seed/pydantic" +# Warm the model cache so the recording doesn't hit the network or print an HF Hub warning. +env -i HOME="$seed" PATH="$SEMBLE_BIN_DIR:/usr/bin:/bin" "$SEMBLE_BIN_DIR/semble" search x "$seed/pydantic" -k 1 >/dev/null + +# Record the demo for both dark and light themes. Each home gets its own agent dirs and repo copy, but shares the +# seed's .cache: grammar dylibs load slowly the first time from a new path, which stalls the start of indexing. +for home in "$dark" "$light"; do + mkdir -p "$home"/.claude "$home"/.cursor "$home"/.codex + cp -R "$seed/pydantic" "$home/pydantic" + ln -s "$seed/.cache" "$home/.cache" +done +DEMO_HOME="$dark" vhs scripts/demo/dark.tape >/dev/null & +dark_pid=$! +DEMO_HOME="$light" vhs scripts/demo/light.tape >/dev/null & +light_pid=$! +wait "$dark_pid" +wait "$light_pid" +echo "Recorded assets/images/demo-dark.gif and assets/images/demo-light.gif" diff --git a/src/semble/index/create.py b/src/semble/index/create.py index 43e066c0..43d67c55 100644 --- a/src/semble/index/create.py +++ b/src/semble/index/create.py @@ -104,7 +104,14 @@ def create_index_from_path( files = list(walk_files(path, resolved_extensions)) for file_path in tqdm( - files, desc="Indexing", unit="file", file=sys.stderr, leave=False, colour="green", disable=not show_progress_bar + files, + desc="Indexing", + unit="file", + file=sys.stderr, + leave=False, + colour="green", + miniters=1, # tqdm's adaptive miniters stalls the bar when fast files are followed by slow ones + disable=not show_progress_bar, ): language = detect_language(file_path) with contextlib.suppress(OSError): diff --git a/src/semble/installer/installer.py b/src/semble/installer/installer.py index 855ddf1a..54b7b96e 100644 --- a/src/semble/installer/installer.py +++ b/src/semble/installer/installer.py @@ -155,6 +155,12 @@ def _checkbox(prompt: str, items: Sequence[tuple[str, _T, bool]]) -> list[_T] | return questionary.checkbox(prompt, choices=choices, style=style, instruction=instruction).ask() +def _display_path(path: Path) -> str: + """Return path with the home directory shortened to ~, keeping installer output readable.""" + home = Path.home() + return str(Path("~") / path.relative_to(home)) if path.is_relative_to(home) else str(path) + + def _print_plan(agents: list[AgentTarget], integrations: list[_Integration]) -> None: """Print what will be written or removed for each selected agent and integration.""" print(f"\n {_BOLD}Plan:{_RESET}\n") @@ -162,8 +168,8 @@ def _print_plan(agents: list[AgentTarget], integrations: list[_Integration]) -> print(f" {_BOLD}{agent.display_name}{_RESET}") for integ in integrations: path = integ.plan_path(agent) - ok = path is not None - print(f" {integ.label:<13} {_tick(ok)} {path if ok else '(not supported)'}") + shown = _display_path(path) if path is not None else "(not supported)" + print(f" {integ.label:<13} {_tick(path is not None)} {shown}") print() @@ -180,7 +186,7 @@ def _apply(mode: Mode, agents: list[AgentTarget], integrations: list[_Integratio ok = result.action in ("created", "updated", "removed", "unchanged") detail = _ACTION_DETAIL.get(result.action, "") suffix = f" — {detail}" if detail else "" - print(f" {_tick(ok)} {integ.id.value} ({result.action}){suffix} → {result.path}") + print(f" {_tick(ok)} {integ.id.value} ({result.action}){suffix} → {_display_path(result.path)}") print() diff --git a/tests/test_installer.py b/tests/test_installer.py index a350ac29..99f51ffe 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -520,13 +520,17 @@ def ask(self): assert _checkbox("Pick:", [("Option A", "a", False)]) == ["a"] -def test_print_plan(capsys, claude_agent): - """_print_plan prints each agent, integration, and resolved path (or 'not supported').""" +@pytest.mark.parametrize("under_home", [True, False]) +def test_print_plan(capsys, claude_agent, monkeypatch, tmp_path, under_home): + """_print_plan prints each agent, integration, and path (shortened to ~ under home, or 'not supported').""" + monkeypatch.setattr(Path, "home", lambda: tmp_path if under_home else tmp_path / "elsewhere") no_mcp = replace(claude_agent, display_name="No MCP Agent", mcp=None) _print_plan([claude_agent, no_mcp], _INTEGRATIONS) out = capsys.readouterr().out assert "Claude Code" in out assert "not supported" in out # no_mcp has no MCP + expected = Path("~") / ".claude" / "CLAUDE.md" if under_home else claude_agent.instructions_path + assert str(expected) in out def test_run_completes(run_setup, monkeypatch, capsys): diff --git a/uv.lock b/uv.lock index 26a5ca17..6e5bd760 100644 --- a/uv.lock +++ b/uv.lock @@ -13,7 +13,7 @@ resolution-markers = [ [options] exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. -exclude-newer-span = "P3D" +exclude-newer-span = "P1W" [options.exclude-newer-package] semble-grammars = false @@ -38,16 +38,16 @@ wheels = [ [[package]] name = "anyio" -version = "4.15.1" +version = "4.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.15'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/9a/c15a60547004a3f3cea20296c934f827ddd7bdba225a2e7e9fcb5ec48c80/anyio-4.15.0.tar.gz", hash = "sha256:b5c620ed540725e2579c31b17bb995b3bf02c9281c9cace04c7d186380bab85e", size = 276504, upload-time = "2026-09-02T21:46:36.957Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/2b21ce5ebe4d8938a247c9b0dbb7271566ae559b01795c83ea4bb2660ed7/anyio-4.15.0-py3-none-any.whl", hash = "sha256:7ecd9937369ffce8bba0b5ccb9b3a9507b101b0ed50256aecfbab27e6c2acb99", size = 131908, upload-time = "2026-09-02T21:46:35.485Z" }, ] [[package]]