diff --git a/.gitignore b/.gitignore index b0e6894..e26c544 100644 --- a/.gitignore +++ b/.gitignore @@ -23,9 +23,8 @@ env/ .DS_Store Thumbs.db -# hedwig-cg databases (generated) -.hedwig-cg/ -.hedwig-cb/ +# codeloom databases (generated) +.codeloom/ # Claude Code .claude/ @@ -41,7 +40,8 @@ htmlcov/ # Internal review feedback (not for public repo) feedbacks/ -# Generated integration files (from hedwig-cg install commands) +# Generated integration files (from codeloom install commands) +.opencode/ .cursor/ .windsurf/ .codex/ @@ -49,6 +49,7 @@ AGENTS.md GEMINI.md CONVENTIONS.md .aider.conf.yml +opencode.json # OMC state .omc/ diff --git a/README.md b/README.md index ad13bbb..7125cbc 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,10 @@ Python 3.10+

+

+ codeloom visualization +

+ --- ## Why codeloom? @@ -84,9 +88,13 @@ Text nodes (docs, comments, markdown) are embedded with `intfloat/multilingual-e When integrated with AI coding agents (Claude Code, Codex, etc.), codeloom **automatically rebuilds** the graph when code changes. The Stop/SessionEnd hook detects modified files via `git diff` and triggers an incremental rebuild in the background — zero manual intervention. -### Smart Ignore +### Git-Accelerated Deltas + +Uses `git diff` to bypass heavy I/O of full filesystem scans. Only changed files are processed, enabling sub-second updates for typical logic changes. Toggle with `--git`. + +### Smart Ignore & Pruning -codeloom respects ignore patterns from three sources, all using **full gitignore spec** (negation `!`, `**` globs, directory-only patterns): +codeloom respects ignore patterns from three sources and explicitly **prunes deleted files**, ensuring no "ghost nodes" remain in your graph after files are removed or renamed. | Source | Description | |--------|-------------| @@ -96,7 +104,7 @@ codeloom respects ignore patterns from three sources, all using **full gitignore ### Incremental Builds -SHA-256 content hashing per file. Only changed files are re-extracted and re-embedded. Unchanged files are merged from the existing graph — typically **95%+ faster** than a full rebuild. +SHA-256 content hashing per file and **hot-start PageRank**. Only changed files are re-extracted and re-embedded, while previous importance scores are reused for rapid convergence — typically **95%+ faster** than a full rebuild. ### Memory Management @@ -150,7 +158,11 @@ All commands output compact text by default (designed for AI agent consumption). | Command | Description | |---------|-------------| -| `build ` | Build code graph (`--incremental`) | +| `build ` | Build code graph (`--incremental`, `--git`) | +| `watch ` | Real-time file system monitor for instant graph sync | +| `impact ` | Analyze "blast radius" — find all downstream dependents | +| `dependencies ` | Analyze upstream dependencies for a given symbol | +| `setup` | One-step automated setup for all detected agents | | `search ` | Hybrid vector + keyword search with subgraph and snippets (`--top-k`, `--fast`, `--kind`, `--file`, `--include-tests`, `--snippets`) | | `search-vector ` | Vector similarity only (code + text dual model) | | `search-keyword ` | FTS5 keyword matching only (BM25 ranking) | diff --git a/codeloom/__init__.py b/codeloom/__init__.py index f1e83a2..b32bab8 100644 --- a/codeloom/__init__.py +++ b/codeloom/__init__.py @@ -2,6 +2,6 @@ for AI coding agents. """ -__version__ = "0.1.2" +__version__ = "0.1.3" __all__ = ["__version__", "core", "query", "storage", "cli"] diff --git a/codeloom/cli/_helpers.py b/codeloom/cli/_helpers.py index b6406c6..9b96be2 100644 --- a/codeloom/cli/_helpers.py +++ b/codeloom/cli/_helpers.py @@ -104,13 +104,42 @@ def human_choose( choices: list[str], descriptions: list[str] | None = None, default: int = 1, -) -> str: - """Show an interactive arrow-key menu and return the chosen value. + multiple: bool = False, +) -> str | list[str]: + """Show an interactive menu and return the chosen value(s). Falls back to numbered input if terminal doesn't support raw mode. """ import sys + if multiple: + # For multiple selection, we always use numbered fallback + msg = f"{prompt} {_DIM}(Enter numbers separated by comma){_RESET}\n" + click.echo(msg) + for i, choice in enumerate(choices, 1): + desc = ( + f" {_DIM}{descriptions[i - 1]}{_RESET}" if descriptions else "" + ) + click.echo(f" {i}) {choice}{desc}") + click.echo() + + while True: + raw = click.prompt("Selection", default=str(default)) + try: + indices = [int(x.strip()) for x in raw.split(",")] + return [ + choices[i - 1] for i in indices if 1 <= i <= len(choices) + ] + except (ValueError, IndexError): + # Check for direct name matches + names = [x.strip().lower() for x in raw.split(",")] + selected = [c for c in choices if c.lower() in names] + if selected: + return selected + msg = "Invalid selection. Use numbers (e.g. 1,2) or names." + click.echo(msg) + continue + selected = default - 1 # 0-based def _render(sel: int, clear: bool = False) -> None: diff --git a/codeloom/cli/integrations.py b/codeloom/cli/integrations.py index 8cdc954..cfd3e57 100644 --- a/codeloom/cli/integrations.py +++ b/codeloom/cli/integrations.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json as _json from pathlib import Path import click @@ -20,6 +21,7 @@ def get_codeloom_context() -> str: """Returns the authoritative context rules for AI agents.""" return ( + "\n" "## codeloom\n\n" "This project has a codeloom code graph at `.codeloom/`.\n\n" "Rules:\n" @@ -36,28 +38,226 @@ def get_codeloom_context() -> str: "- After modifying code files, run `codeloom build . --incremental` " "to keep your mental model and the graph current.\n" "- Use `codeloom stats` for structural overview (god nodes, " - "communities, density).\n\n" + "communities, density).\n" + "\n\n" ) -def prepend_codeloom_context(file_path: Path, marker: str = "## codeloom"): - """Prepends codeloom context to a file, or updates it if present.""" +def sync_codeloom_context(file_path: Path): + """Surgically updates or prepends codeloom context to a file.""" + import re + new_context = get_codeloom_context() + start_marker = "" + end_marker = "" + if not file_path.exists(): file_path.write_text(new_context) human_ok(f"{file_path.name} created with codeloom rules at the TOP.") return content = file_path.read_text() - if marker in content: - # If it exists, we don't move it to top to avoid messing with - # user edits, but we could. For now, let's just skip or update in place. - human_skip(f"{file_path.name} already contains codeloom section.") + + # Determine ideal position (after frontmatter if present) + frontmatter_pattern = re.compile(r"^---\s*\n.*?\n---\s*\n", re.DOTALL) + fm_match = frontmatter_pattern.match(content) + fm_text = fm_match.group(0) if fm_match else "" + main_content = content[len(fm_text) :] + + # Try to find the marked block in the whole file + block_pattern = re.compile( + f"{re.escape(start_marker)}.*?{re.escape(end_marker)}\\s*", + re.DOTALL, + ) + match = block_pattern.search(content) + + if match: + # Block found. Check if it's already identical + existing_block = match.group(0) + if existing_block.strip() == new_context.strip(): + # Already identical. Now check positioning. + # It should be immediately after frontmatter. + if main_content.lstrip().startswith(start_marker): + msg = f"{file_path.name} context is up-to-date and at TOP." + human_skip(msg) + return + else: + # Move to correct position + clean_main = block_pattern.sub("", main_content).lstrip() + new_file_content = fm_text + new_context + clean_main + file_path.write_text(new_file_content) + human_ok(f"Moved codeloom context to TOP of {file_path.name}.") + return + + # Content differs or position is wrong. + clean_main = block_pattern.sub("", main_content).lstrip() + new_file_content = fm_text + new_context + clean_main + file_path.write_text(new_file_content) + human_ok(f"Updated and moved codeloom context in {file_path.name}.") + else: + # No marked block found. Check for legacy unmarked header. + legacy_marker = "## codeloom" + if legacy_marker in content: + # Safety fallback: Prepend marked block after FM and leave + # legacy alone + new_file_content = fm_text + new_context + main_content + file_path.write_text(new_file_content) + human_warn( + f"Legacy context found in {file_path.name}. " + "Prepended new marked block at TOP." + ) + else: + # Clean file. Just prepend after FM. + new_file_content = fm_text + new_context + main_content.lstrip() + file_path.write_text(new_file_content) + human_ok(f"Prepended codeloom rules to {file_path.name}.") + + +def uninstall_codeloom_context(file_path: Path): + """Removes the marked codeloom block from a file.""" + import re + + if not file_path.exists(): + human_skip(f"{file_path.name} not found") + return + + content = file_path.read_text() + start_marker = "" + end_marker = "" + + pattern = re.compile( + f"{re.escape(start_marker)}.*?{re.escape(end_marker)}\\s*", + re.DOTALL, + ) + + if pattern.search(content): + new_content = pattern.sub("", content).strip() + if new_content: + file_path.write_text(new_content + "\n") + else: + file_path.unlink() + human_ok(f"Removed codeloom context from {file_path.name}") + else: + # Fallback for legacy unmarked header + lines = content.splitlines(keepends=True) + filtered = [] + skip = False + for line in lines: + if line.strip() == "## codeloom": + skip = True + continue + if ( + skip + and line.startswith("##") + and "codeloom" not in line.lower() + ): + skip = False + if not skip: + filtered.append(line) + + new_content = "".join(filtered).strip() + if new_content: + file_path.write_text(new_content + "\n") + else: + file_path.unlink() + human_ok(f"Removed legacy codeloom section from {file_path.name}") + + +def sync_skill_file(source: Path, dest: Path, force: bool = False): + """Updates a skill file if it's an official version or forced.""" + import hashlib + + if not source.exists(): + human_warn(f"Skill source not found: {source}") + return + + def get_hash(p: Path) -> str: + return hashlib.sha256(p.read_bytes()).hexdigest() + + if not dest.exists(): + import shutil + + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, dest) + human_ok(f"Skill installed to {dest.parent}/") + return + + # Compare hashes + src_hash = get_hash(source) + dst_hash = get_hash(dest) + + if src_hash == dst_hash: + human_skip(f"Skill in {dest.parent} is already up-to-date.") + return + + if force: + import shutil + + shutil.copy2(source, dest) + human_ok(f"Force-updated skill in {dest.parent}/") + else: + human_warn( + f"Skill in {dest.parent}/ has manual edits. " + "Use --force to overwrite." + ) + + +def merge_json_config(file_path: Path, new_data: dict, key_path: list[str]): + """Surgically merges data into a JSON file.""" + import json + + if not file_path.exists(): + # Create new with proper structure + data = {} + curr = data + for k in key_path[:-1]: + curr[k] = {} + curr = curr[k] + curr[key_path[-1]] = new_data + file_path.write_text(json.dumps(data, indent=2) + "\n") + human_ok(f"Created {file_path.name} with codeloom config.") + return + + try: + data = json.loads(file_path.read_text()) + except Exception as e: + human_warn(f"Could not parse {file_path.name}: {e}. Skipping update.") return - # Prepend to ensure it's the first thing read - file_path.write_text(new_context + content) - human_ok(f"codeloom rules prepended to {file_path.name}.") + # Drill down to the target location + curr = data + for k in key_path[:-1]: + curr = curr.setdefault(k, {}) + + target_key = key_path[-1] + + # In settings.json, hooks is usually a dict of lists + if target_key == "hooks" and isinstance(new_data, dict): + hooks = curr.setdefault("hooks", {}) + for event, entry_list in new_data.items(): + existing_event_hooks = hooks.setdefault(event, []) + already = any( + "codeloom" in _json.dumps(h) for h in existing_event_hooks + ) + if not already: + existing_event_hooks.extend(entry_list) + human_ok(f"Added codeloom {event} hook to {file_path.name}.") + else: + msg = f"codeloom {event} hook already in {file_path.name}." + human_skip(msg) + else: + # Standard dict update + if ( + target_key in curr + and isinstance(curr[target_key], dict) + and isinstance(new_data, dict) + ): + curr[target_key].update(new_data) + else: + curr[target_key] = new_data + human_ok(f"Updated {target_key} in {file_path.name}.") + + file_path.write_text(_json.dumps(data, indent=2) + "\n") # ─── Claude Code ───────────────────────────────────────────────────────────── @@ -74,21 +274,13 @@ def claude_group(): "--scope", type=click.Choice(["user", "project"], case_sensitive=False), default=None, - help="Install scope: 'user' (global ~/.claude/skills/) or " - "'project' (.claude/skills/). " - "If omitted, you will be prompted to choose.", + help="Install scope: 'user' (global) or 'project' (local).", ) -def claude_install(scope: str | None): - """Install Claude Code integration. - - Priority: 1) Skill 2) CLAUDE.md + hooks 3) MCP - """ - import json - import shutil - +@click.option("--force", is_flag=True, help="Overwrite manual skill edits.") +def claude_install(scope: str | None, force: bool = False): + """Install Claude Code integration.""" project_root = Path.cwd() - # --- Prompt for scope if not specified --- if scope is None: scope = human_choose( "Where should codeloom be installed?", @@ -102,112 +294,75 @@ def claude_install(scope: str | None): human_header(f"Installing codeloom for Claude Code (scope: {scope})") - # --- Priority 1: Install Skill --- skill_source = Path(__file__).parent.parent / "skill.md" if scope == "user": skill_dir = Path.home() / ".claude" / "skills" / "codeloom" else: skill_dir = project_root / ".claude" / "skills" / "codeloom" - skill_dir.mkdir(parents=True, exist_ok=True) skill_dest = skill_dir / "SKILL.md" + sync_skill_file(skill_source, skill_dest, force=force) - if skill_source.exists(): - shutil.copy2(skill_source, skill_dest) - human_ok(f"Skill installed to {skill_dir}/") - else: - human_warn("Skill source not found") - - # --- Priority 2: CLAUDE.md + hooks --- - # 1. Write section to project CLAUDE.md claude_md = project_root / "CLAUDE.md" - prepend_codeloom_context(claude_md) + sync_codeloom_context(claude_md) - # 2. Write PreToolUse hook to .claude/settings.json settings_dir = project_root / ".claude" settings_dir.mkdir(parents=True, exist_ok=True) settings_file = settings_dir / "settings.json" - pre_hook_entry = { - "matcher": "Glob|Grep", - "hooks": [ + new_hooks = { + "PreToolUse": [ { - "type": "command", - "command": ( - "[ -f .codeloom/knowledge.db ] && echo " - '\'{"hookSpecificOutput":{"hookEventName":"PreToolUse",' - '"additionalContext":"codeloom: 5-signal code graph ' - 'available. STOP grepping. Use `codeloom search ' - '\\"\\"` for far better results. Use `codeloom ' - 'impact \\"\\"` before editing."' - "\"}}' || true" - ), + "matcher": "Glob|Grep", + "hooks": [ + { + "type": "command", + "command": ( + "[ -f .codeloom/knowledge.db ] && echo " + '\'{"hookSpecificOutput":{"hookEventName":' + '"PreToolUse","additionalContext":"codeloom: ' + '5-signal code graph available. STOP grepping. ' + 'Use `codeloom search \\"\\"` for far ' + 'better results. Use `codeloom impact ' + '\\"\\"` before editing."' + "\"}}' || true" + ), + } + ], } ], - } - - post_hook_entry = { - "matcher": "Write|Edit", - "hooks": [ + "PostToolUse": [ { - "type": "command", - "command": ( - "echo '{\"hookSpecificOutput\":{\"hookEventName\":" - "\"PostToolUse\",\"additionalContext\":\"codeloom: " - "Changes detected. Run `codeloom build . --incremental` " - "to update the code graph and your mental model.\"}}'" - ), + "matcher": "Write|Edit", + "hooks": [ + { + "type": "command", + "command": ( + "echo '{\"hookSpecificOutput\":{\"hookEventName\":" + "\"PostToolUse\",\"additionalContext\":\"codeloom: " + "Changes detected. Run `codeloom build . " + "--incremental` to update the code graph and " + "your mental model.\"}}'" + ), + } + ], } ], - } - - if settings_file.exists(): - settings = json.loads(settings_file.read_text()) - else: - settings = {} - - hooks = settings.setdefault("hooks", {}) - - # PreToolUse - pre_hooks = hooks.setdefault("PreToolUse", []) - if any("codeloom" in json.dumps(h) for h in pre_hooks): - human_skip("PreToolUse hook already exists") - else: - pre_hooks.append(pre_hook_entry) - human_ok("PreToolUse hook added (Grep/Glob interception)") - - # PostToolUse - post_hooks = hooks.setdefault("PostToolUse", []) - if any("codeloom" in json.dumps(h) for h in post_hooks): - human_skip("PostToolUse hook already exists") - else: - post_hooks.append(post_hook_entry) - human_ok("PostToolUse hook added (change detection)") - - # 3. Write Stop hook for auto-rebuild - stop_hook_entry = { - "matcher": "*", - "hooks": [ + "Stop": [ { - "type": "command", - "command": auto_rebuild_command(), - "timeout": 10, + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": auto_rebuild_command(), + "timeout": 10, + } + ], } ], } - stop_hooks = hooks.setdefault("Stop", []) - stop_already = any( - "codeloom" in json.dumps(h) or "auto_rebuild" in json.dumps(h) - for h in stop_hooks - ) - if stop_already: - human_skip("Stop hook (auto-rebuild) already exists") - else: - stop_hooks.append(stop_hook_entry) - human_ok("Stop hook (auto-rebuild) added") - - settings_file.write_text(json.dumps(settings, indent=2) + "\n") + merge_json_config(settings_file, new_hooks, ["hooks"]) human_done("Done! Run 'codeloom build .' to create your first code graph.") @@ -219,14 +374,12 @@ def claude_install(scope: str | None): help="Uninstall scope: 'user', 'project', or 'all' (default).", ) def claude_uninstall(scope: str): - """Remove Claude Code integration (skill + CLAUDE.md + hooks).""" - import json + """Remove Claude Code integration.""" import shutil human_header("Removing codeloom from Claude Code") project_root = Path.cwd() - # 0. Remove skill if scope in ("user", "all"): user_skill = Path.home() / ".claude" / "skills" / "codeloom" if user_skill.exists(): @@ -238,49 +391,28 @@ def claude_uninstall(scope: str): shutil.rmtree(proj_skill) human_ok("Project skill removed") - # 1. Remove section from CLAUDE.md claude_md = project_root / "CLAUDE.md" - if claude_md.exists(): - lines = claude_md.read_text().splitlines(keepends=True) - filtered = [] - skip = False - for line in lines: - if line.strip() == "## codeloom": - skip = True - continue - if ( - skip - and line.startswith("##") - and "codeloom" not in line.lower() - ): - skip = False - if skip: - continue - filtered.append(line) - new_content = "".join(filtered).rstrip("\n") + "\n" - claude_md.write_text(new_content) - human_ok("codeloom section removed from CLAUDE.md") + uninstall_codeloom_context(claude_md) - # 2. Remove hooks from .claude/settings.json settings_file = project_root / ".claude" / "settings.json" if settings_file.exists(): - settings = json.loads(settings_file.read_text()) + settings = _json.loads(settings_file.read_text()) hooks = settings.get("hooks", {}) - for event in ("PreToolUse", "Stop"): + for event in ("PreToolUse", "PostToolUse", "Stop"): event_hooks = hooks.get(event, []) hooks[event] = [ h for h in event_hooks if ( - "codeloom" not in json.dumps(h) - and "auto_rebuild" not in json.dumps(h) + "codeloom" not in _json.dumps(h) + and "auto_rebuild" not in _json.dumps(h) ) ] if not hooks[event]: hooks.pop(event, None) if not hooks: settings.pop("hooks", None) - settings_file.write_text(json.dumps(settings, indent=2) + "\n") + settings_file.write_text(_json.dumps(settings, indent=2) + "\n") human_ok("Hooks removed from .claude/settings.json") human_done("codeloom integration removed.") @@ -298,113 +430,68 @@ def codex_group(): @codex_group.command(name="install") def codex_install(): """Install per-project Codex CLI integration (AGENTS.md + hooks.json).""" - import json - human_header("Installing codeloom for Codex CLI...") project_root = Path.cwd() - # 1. Write section to project AGENTS.md agents_md = project_root / "AGENTS.md" - prepend_codeloom_context(agents_md) + sync_codeloom_context(agents_md) - # 2. Write PreToolUse hook to .codex/hooks.json hooks_dir = project_root / ".codex" hooks_dir.mkdir(parents=True, exist_ok=True) hooks_file = hooks_dir / "hooks.json" - hook_entry = { - "matcher": "Bash", - "hooks": [ + new_hooks = { + "PreToolUse": [ { - "type": "command", - "command": ( - "[ -f .codeloom/knowledge.db ] && echo " - '\'{"hookSpecificOutput":{"hookEventName":"PreToolUse",' - '"additionalContext":"codeloom: code graph available. ' - 'Use `codeloom search \\"\\"` (5-signal HybridRAG) ' - "instead of grepping raw files. You can also add " - '`--kind function|class|method` or `--file \\"src/*\\"` ' - "to narrow results." - "\"}}' || true" - ), + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": ( + "[ -f .codeloom/knowledge.db ] && echo " + '\'{"hookSpecificOutput":{"hookEventName":' + '"PreToolUse","additionalContext":"codeloom: ' + 'code graph available. Use `codeloom search ' + '\\"\\"` (5-signal HybridRAG) instead ' + "of grepping raw files. You can also add " + '`--kind function|class|method` or `--file ' + '\\"src/*\\"` to narrow results."' + "\"}}' || true" + ), + } + ], } ], - } - - if hooks_file.exists(): - hooks_data = json.loads(hooks_file.read_text()) - else: - hooks_data = {} - - hooks = hooks_data.setdefault("hooks", {}) - pre_hooks = hooks.setdefault("PreToolUse", []) - - already = any("codeloom" in json.dumps(h) for h in pre_hooks) - if already: - human_skip("PreToolUse hook already exists") - else: - pre_hooks.append(hook_entry) - human_ok("PreToolUse hook added to .codex/hooks.json") - - # 3. Write Stop hook for auto-rebuild - stop_hook_entry = { - "matcher": "*", - "hooks": [ + "Stop": [ { - "type": "command", - "command": auto_rebuild_command(), - "timeout": 10, + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": auto_rebuild_command(), + "timeout": 10, + } + ], } ], } - stop_hooks = hooks.setdefault("Stop", []) - stop_already = any("auto_rebuild" in json.dumps(h) for h in stop_hooks) - if stop_already: - human_skip("Stop hook (auto-rebuild) already exists") - else: - stop_hooks.append(stop_hook_entry) - human_ok("Stop hook (auto-rebuild) added") - - hooks_file.write_text(json.dumps(hooks_data, indent=2) + "\n") + merge_json_config(hooks_file, new_hooks, ["hooks"]) human_done() @codex_group.command(name="uninstall") def codex_uninstall(): """Remove per-project Codex CLI integration.""" - import json - human_header("Removing codeloom from Codex CLI") project_root = Path.cwd() - # 1. Remove section from AGENTS.md agents_md = project_root / "AGENTS.md" - if agents_md.exists(): - lines = agents_md.read_text().splitlines(keepends=True) - filtered = [] - skip = False - for line in lines: - if line.strip() == "## codeloom": - skip = True - continue - if ( - skip - and line.startswith("##") - and "codeloom" not in line.lower() - ): - skip = False - if skip: - continue - filtered.append(line) - new_content = "".join(filtered).rstrip("\n") + "\n" - agents_md.write_text(new_content) - human_ok("codeloom section removed from AGENTS.md") + uninstall_codeloom_context(agents_md) - # 2. Remove hooks from .codex/hooks.json hooks_file = project_root / ".codex" / "hooks.json" if hooks_file.exists(): - hooks_data = json.loads(hooks_file.read_text()) + hooks_data = _json.loads(hooks_file.read_text()) hooks = hooks_data.get("hooks", {}) for event in ("PreToolUse", "Stop"): event_hooks = hooks.get(event, []) @@ -412,15 +499,15 @@ def codex_uninstall(): h for h in event_hooks if ( - "codeloom" not in json.dumps(h) - and "auto_rebuild" not in json.dumps(h) + "codeloom" not in _json.dumps(h) + and "auto_rebuild" not in _json.dumps(h) ) ] if not hooks[event]: hooks.pop(event, None) if not hooks: hooks_data.pop("hooks", None) - hooks_file.write_text(json.dumps(hooks_data, indent=2) + "\n") + hooks_file.write_text(_json.dumps(hooks_data, indent=2) + "\n") human_ok("Hooks removed from .codex/hooks.json") human_done("codeloom integration removed.") @@ -437,117 +524,68 @@ def gemini_group(): @gemini_group.command(name="install") def gemini_install(): - """Install per-project Gemini CLI integration (GEMINI.md + BeforeTool - hook).""" - import json - + """Install per-project Gemini CLI integration (GEMINI.md + hooks).""" human_header("Installing codeloom for Gemini CLI...") project_root = Path.cwd() - # 1. Write section to project GEMINI.md gemini_md = project_root / "GEMINI.md" - prepend_codeloom_context(gemini_md) + sync_codeloom_context(gemini_md) - # 2. Write BeforeTool hook to .gemini/settings.json settings_dir = project_root / ".gemini" settings_dir.mkdir(parents=True, exist_ok=True) settings_file = settings_dir / "settings.json" - hook_entry = { - "matcher": "read_file", - "hooks": [ + new_hooks = { + "BeforeTool": [ { - "type": "command", - "command": ( - "[ -f .codeloom/knowledge.db ] && echo " - '\'{"hookSpecificOutput":{"additionalContext":' - '"codeloom: code graph available. ' - 'Use `codeloom search \\"\\"` (5-signal HybridRAG) ' - "instead of reading raw files. This single command covers " - "vector, graph, keyword, and community search with RRF " - "fusion." - "\"}}' || true" - ), + "matcher": "read_file", + "hooks": [ + { + "type": "command", + "command": ( + "[ -f .codeloom/knowledge.db ] && echo " + '\'{"hookSpecificOutput":{"additionalContext":' + '"codeloom: code graph available. ' + 'Use `codeloom search \\"\\"` (5-signal ' + "HybridRAG) instead of reading raw files. This " + "single command covers vector, graph, keyword, " + 'and community search with RRF fusion."' + "\"}}' || true" + ), + } + ], } ], - } - - if settings_file.exists(): - settings = json.loads(settings_file.read_text()) - else: - settings = {} - - hooks = settings.setdefault("hooks", {}) - before_hooks = hooks.setdefault("BeforeTool", []) - - already = any("codeloom" in json.dumps(h) for h in before_hooks) - if already: - human_skip("BeforeTool hook already exists") - else: - before_hooks.append(hook_entry) - human_ok("BeforeTool hook added to .gemini/settings.json") - - # 3. Write SessionEnd hook for auto-rebuild - session_end_entry = { - "matcher": "*", - "hooks": [ + "SessionEnd": [ { - "type": "command", - "command": auto_rebuild_command(), - "timeout": 10, + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": auto_rebuild_command(), + "timeout": 10, + } + ], } ], } - session_hooks = hooks.setdefault("SessionEnd", []) - session_already = any( - "auto_rebuild" in json.dumps(h) for h in session_hooks - ) - if session_already: - human_skip("SessionEnd hook (auto-rebuild) already exists") - else: - session_hooks.append(session_end_entry) - human_ok("SessionEnd hook (auto-rebuild) added") - - settings_file.write_text(json.dumps(settings, indent=2) + "\n") + merge_json_config(settings_file, new_hooks, ["hooks"]) human_done() @gemini_group.command(name="uninstall") def gemini_uninstall(): """Remove per-project Gemini CLI integration.""" - import json - human_header("Removing codeloom from Gemini CLI") project_root = Path.cwd() - # 1. Remove section from GEMINI.md gemini_md = project_root / "GEMINI.md" - if gemini_md.exists(): - lines = gemini_md.read_text().splitlines(keepends=True) - filtered = [] - skip = False - for line in lines: - if line.strip() == "## codeloom": - skip = True - continue - if ( - skip - and line.startswith("##") - and "codeloom" not in line.lower() - ): - skip = False - if skip: - continue - filtered.append(line) - new_content = "".join(filtered).rstrip("\n") + "\n" - gemini_md.write_text(new_content) - human_ok("codeloom section removed from GEMINI.md") + uninstall_codeloom_context(gemini_md) - # 2. Remove hooks from .gemini/settings.json settings_file = project_root / ".gemini" / "settings.json" if settings_file.exists(): - settings = json.loads(settings_file.read_text()) + settings = _json.loads(settings_file.read_text()) hooks = settings.get("hooks", {}) for event in ("BeforeTool", "SessionEnd"): event_hooks = hooks.get(event, []) @@ -555,15 +593,15 @@ def gemini_uninstall(): h for h in event_hooks if ( - "codeloom" not in json.dumps(h) - and "auto_rebuild" not in json.dumps(h) + "codeloom" not in _json.dumps(h) + and "auto_rebuild" not in _json.dumps(h) ) ] if not hooks[event]: hooks.pop(event, None) if not hooks: settings.pop("hooks", None) - settings_file.write_text(json.dumps(settings, indent=2) + "\n") + settings_file.write_text(_json.dumps(settings, indent=2) + "\n") human_ok("Hooks removed from .gemini/settings.json") human_done("codeloom integration removed.") @@ -588,24 +626,18 @@ def cursor_install(): rules_dir.mkdir(parents=True, exist_ok=True) rules_file = rules_dir / "codeloom.mdc" - rule_content = ( - "---\n" - "description: codeloom code graph search rules\n" - "globs: **/*\n" - "alwaysApply: true\n" - "---\n\n" - ) + get_codeloom_context() - - if rules_file.exists(): - content = rules_file.read_text() - if "codeloom" in content: - human_skip(".cursor/rules/codeloom.mdc already exists") - else: - rules_file.write_text(rule_content) - human_ok(".cursor/rules/codeloom.mdc updated") - else: + if not rules_file.exists(): + rule_content = ( + "---\n" + "description: codeloom code graph search rules\n" + "globs: **/*\n" + "alwaysApply: true\n" + "---\n\n" + ) + get_codeloom_context() rules_file.write_text(rule_content) human_ok(".cursor/rules/codeloom.mdc created") + else: + sync_codeloom_context(rules_file) human_done() @@ -646,19 +678,7 @@ def windsurf_install(): rules_dir.mkdir(parents=True, exist_ok=True) rules_file = rules_dir / "codeloom.md" - rule_content = get_codeloom_context() - - if rules_file.exists(): - content = rules_file.read_text() - if "codeloom" in content: - human_skip(".windsurf/rules/codeloom.md already exists") - else: - rules_file.write_text(rule_content) - human_ok(".windsurf/rules/codeloom.md updated") - else: - rules_file.write_text(rule_content) - human_ok(".windsurf/rules/codeloom.md created") - + sync_codeloom_context(rules_file) human_done() @@ -669,12 +689,7 @@ def windsurf_uninstall(): project_root = Path.cwd() rules_file = project_root / ".windsurf" / "rules" / "codeloom.md" - if rules_file.exists(): - rules_file.unlink() - human_ok(".windsurf/rules/codeloom.md removed") - else: - human_skip(".windsurf/rules/codeloom.md not found") - + uninstall_codeloom_context(rules_file) human_done("codeloom integration removed.") @@ -694,22 +709,7 @@ def cline_install(): project_root = Path.cwd() rules_file = project_root / ".clinerules" - - rule_content = get_codeloom_context() - - if rules_file.exists(): - content = rules_file.read_text() - if "codeloom" in content: - human_skip(".clinerules section already exists") - else: - # Append to existing rules - with open(rules_file, "a") as f: - f.write("\n\n" + rule_content) - human_ok("codeloom section appended to .clinerules") - else: - rules_file.write_text(rule_content) - human_ok(".clinerules created") - + sync_codeloom_context(rules_file) human_done() @@ -720,39 +720,14 @@ def cline_uninstall(): project_root = Path.cwd() rules_file = project_root / ".clinerules" - if rules_file.exists(): - content = rules_file.read_text() - if "codeloom" in content: - # Remove codeloom section - lines = content.split("\n") - filtered = [] - skip = False - for line in lines: - if line.strip() == "# codeloom": - skip = True - continue - if skip and line.startswith("# ") and "codeloom" not in line: - skip = False - if not skip: - filtered.append(line) - new_content = "\n".join(filtered).strip() - if new_content: - rules_file.write_text(new_content + "\n") - human_ok("codeloom section removed from .clinerules") - else: - rules_file.unlink() - human_ok(".clinerules removed (was only codeloom content)") - else: - human_skip("No codeloom section found in .clinerules") - else: - human_skip(".clinerules not found") - + uninstall_codeloom_context(rules_file) human_done("codeloom integration removed.") # ─── Aider CLI ─────────────────────────────────────────────────────────────── + @click.group(name="aider") def aider_group(): """Manage per-project Aider CLI integration.""" @@ -768,11 +743,9 @@ def aider_install(): human_header("Installing codeloom for Aider CLI...") project_root = Path.cwd() - # 1. Write CONVENTIONS.md with codeloom rules conventions_md = project_root / "CONVENTIONS.md" - prepend_codeloom_context(conventions_md) + sync_codeloom_context(conventions_md) - # 2. Ensure .aider.conf.yml loads CONVENTIONS.md via read: conf_file = project_root / ".aider.conf.yml" if conf_file.exists(): conf = yaml.safe_load(conf_file.read_text()) or {} @@ -801,30 +774,9 @@ def aider_uninstall(): human_header("Removing codeloom from Aider CLI") project_root = Path.cwd() - # 1. Remove section from CONVENTIONS.md conventions_md = project_root / "CONVENTIONS.md" - if conventions_md.exists(): - lines = conventions_md.read_text().splitlines(keepends=True) - filtered = [] - skip = False - for line in lines: - if line.strip() == "## codeloom": - skip = True - continue - if ( - skip - and line.startswith("##") - and "codeloom" not in line.lower() - ): - skip = False - if skip: - continue - filtered.append(line) - new_content = "".join(filtered).rstrip("\n") + "\n" - conventions_md.write_text(new_content) - human_ok("codeloom section removed from CONVENTIONS.md") + uninstall_codeloom_context(conventions_md) - # 2. Remove CONVENTIONS.md from .aider.conf.yml read list conf_file = project_root / ".aider.conf.yml" if conf_file.exists(): conf = yaml.safe_load(conf_file.read_text()) or {} @@ -860,20 +812,11 @@ def opencode_group(): "--scope", type=click.Choice(["user", "project"], case_sensitive=False), default=None, - help="Install scope: 'user' (global ~/.config/opencode/skills/) " - "or 'project' (.opencode/skills/). " - "If omitted, you will be prompted to choose.", + help="Install scope: 'user' (global) or 'project' (local).", ) -def opencode_install(scope: str | None): - """Install the codeloom skill for OpenCode. - - Writes the skill file to .opencode/skills/codeloom/SKILL.md or - ~/.config/opencode/skills/codeloom/SKILL.md depending on scope. - OpenCode discovers skills automatically from the skills directory. - """ - import json - import shutil - +@click.option("--force", is_flag=True, help="Overwrite manual skill edits.") +def opencode_install(scope: str | None, force: bool = False): + """Install the codeloom skill for OpenCode.""" project_root = Path.cwd() if scope is None: @@ -896,53 +839,26 @@ def opencode_install(scope: str | None): else: skill_dir = project_root / ".opencode" / "skills" / "codeloom" - skill_dir.mkdir(parents=True, exist_ok=True) skill_dest = skill_dir / "SKILL.md" + sync_skill_file(skill_source, skill_dest, force=force) - if skill_dest.exists(): - human_skip("SKILL.md already exists") - elif skill_source.exists(): - shutil.copy2(skill_source, skill_dest) - human_ok(f"Skill installed to {skill_dir}/") - else: - human_warn("Skill source not found") + # Manage project context file + agents_md = project_root / "AGENTS.md" + sync_codeloom_context(agents_md) - # Auto-register MCP config for OpenCode if scope == "user": mcp_config_path = Path.home() / ".config" / "opencode" / "config.json" else: mcp_config_path = project_root / "opencode.json" mcp_codeloom_config = { - "mcp": { - "codeloom": { - "type": "local", - "command": ["codeloom", "mcp"], - } + "codeloom": { + "type": "local", + "command": ["codeloom", "mcp"], } } - try: - mcp_config_path.parent.mkdir(parents=True, exist_ok=True) - if mcp_config_path.exists(): - existing = json.loads(mcp_config_path.read_text()) - else: - existing = {} - existing.setdefault("mcp", {}).update(mcp_codeloom_config["mcp"]) - mcp_config_path.write_text(json.dumps(existing, indent=2) + "\n") - human_ok(f"MCP config written to {mcp_config_path}") - except Exception as e: - human_warn(f"Could not write MCP config: {e}") - human_ok("To add manually, add to opencode.json:") - click.echo( - ' "mcp": {\n' - ' "codeloom": {\n' - ' "type": "local",\n' - ' "command": ["codeloom", "mcp"]\n' - " }\n" - " }" - ) - + merge_json_config(mcp_config_path, mcp_codeloom_config, ["mcp"]) human_done( "Done! OpenCode will discover the skill and MCP tools automatically." ) @@ -973,7 +889,9 @@ def opencode_uninstall(scope: str): else: human_skip(f"{skill_dir}/ not found") - # Clean up empty parent directories + agents_md = project_root / "AGENTS.md" + uninstall_codeloom_context(agents_md) + parent = skill_dir.parent if parent.exists() and not any(parent.iterdir()): parent.rmdir() @@ -982,6 +900,47 @@ def opencode_uninstall(scope: str): human_done("codeloom integration removed.") +# ─── Detection Logic ────────────────────────────────────────────────────────── + + +def detect_agents() -> list[str]: + """Detect which agents are present on the machine or in the project.""" + import shutil + + detected = [] + project_root = Path.cwd() + + # Heuristics - mostly restricted to project root to avoid false positives + # in test environments (which share the real home directory). + heuristics = { + "claude": lambda: shutil.which("claude") + or (project_root / ".claude").exists() + or (project_root / "CLAUDE.md").exists(), + "opencode": lambda: shutil.which("opencode") + or (project_root / "opencode.json").exists() + or (project_root / ".opencode").exists(), + "aider": lambda: shutil.which("aider") + or (project_root / ".aider.conf.yml").exists() + or (project_root / "CONVENTIONS.md").exists(), + "cursor": lambda: (project_root / ".cursor").exists(), + "windsurf": lambda: (project_root / ".windsurf").exists(), + "cline": lambda: (project_root / ".clinerules").exists(), + "codex": lambda: (project_root / ".codex").exists() + or (project_root / "AGENTS.md").exists(), + "gemini": lambda: (project_root / ".gemini").exists() + or (project_root / "GEMINI.md").exists(), + } + + for agent, check in heuristics.items(): + try: + if check(): + detected.append(agent) + except Exception: + pass + + return detected + + # ─── Register all groups ───────────────────────────────────────────────────── diff --git a/codeloom/cli/main.py b/codeloom/cli/main.py index 4d4808d..c28bea4 100644 --- a/codeloom/cli/main.py +++ b/codeloom/cli/main.py @@ -37,60 +37,234 @@ def cli(ctx): @cli.command() +@click.argument("platform", required=False) @click.option( "--scope", type=click.Choice(["user", "project"], case_sensitive=False), default=None, help="Installation scope", ) +@click.option("--force", is_flag=True, help="Overwrite manual skill edits.") @click.pass_context -def setup(ctx, scope: str | None): - """One-step setup for all AI agent integrations. +def setup(ctx, platform: str | None, scope: str | None, force: bool): + """Unified setup for AI agent integrations. - Detects installed editors (Claude Code, Cursor, Windsurf, etc.) - and configures MCP, skills, and context files automatically. + With no arguments, it intelligently detects present agents and + configures them. Use 'codeloom setup ' to pinpoint a specific + agent (e.g., claude, cursor, aider). """ - from ._helpers import human_choose, human_done, human_header + from ._helpers import ( + human_choose, + human_done, + human_fail, + human_header, + human_ok, + ) from .integrations import ( aider_install, claude_install, cline_install, codex_install, cursor_install, + detect_agents, gemini_install, opencode_install, windsurf_install, ) - if scope is None: - scope = human_choose( - "Default installation scope?", - ["user", "project"], - descriptions=[ - "Global (~/.config/) — works for all projects", - "Local (./.claude/ etc.) — this project only", - ], - default=1, - ) + agent_registry = { + "claude": claude_install, + "opencode": opencode_install, + "aider": aider_install, + "cursor": cursor_install, + "windsurf": windsurf_install, + "cline": cline_install, + "codex": codex_install, + "gemini": gemini_install, + } + + if platform: + if platform.lower() not in agent_registry: + valid = ", ".join(agent_registry.keys()) + human_fail(f"Unknown agent: {platform}. Valid: {valid}") + return + to_install = [platform.lower()] + else: + # Intelligent detection + human_header("Scanning for AI agents...") + detected = detect_agents() + if detected: + msg = f"Detected agents: {', '.join(detected)}" + human_ok(msg) + confirm_msg = "Proceed with setup for these agents?" + if click.confirm(confirm_msg, default=True): + to_install = detected + else: + return + else: + human_warn("No agents detected automatically.") + to_install = human_choose( + "Pick agents to configure manually:", + list(agent_registry.keys()), + multiple=True, + ) + + if not to_install: + human_done("Nothing to install.") + return human_header("codeloom: Automated Agent Setup") - # We run all installers. They are designed to be safe - # (skip if not applicable) though some currently prompt or assume paths. - # In a real setup we might check if the editor is actually installed first. + for agent in to_install: + installer = agent_registry[agent] + # Use ctx.invoke to prevent sys.argv parsing issues + if agent in ("claude", "opencode"): + ctx.invoke(installer, scope=scope, force=force) + else: + ctx.invoke(installer) + + human_done("Setup complete! Agents are now codeloom-aware.") + + +@cli.command() +@click.argument("platform", required=False) +@click.pass_context +def uninstall(ctx, platform: str | None): + """Unified removal of AI agent integrations. + + With no arguments, it scans for codeloom footprints and removes + them. Use 'codeloom uninstall ' to remove a specific agent. + """ + from ._helpers import human_done, human_fail, human_header, human_ok + from .integrations import ( + aider_uninstall, + claude_uninstall, + cline_uninstall, + codex_uninstall, + cursor_uninstall, + detect_agents, + gemini_uninstall, + opencode_uninstall, + windsurf_uninstall, + ) + + agent_registry = { + "claude": claude_uninstall, + "opencode": opencode_uninstall, + "aider": aider_uninstall, + "cursor": cursor_uninstall, + "windsurf": windsurf_uninstall, + "cline": cline_uninstall, + "codex": codex_uninstall, + "gemini": gemini_uninstall, + } + + if platform: + if platform.lower() not in agent_registry: + human_fail(f"Unknown agent: {platform}") + return + to_remove = [platform.lower()] + else: + # Footprint scan (reuse detection heuristics) + human_header("Scanning for codeloom footprints...") + detected = detect_agents() + if detected: + msg = f"Found codeloom in: {', '.join(detected)}" + human_ok(msg) + if click.confirm("Remove these integrations?", default=True): + to_remove = detected + else: + return + else: + human_done("No codeloom integrations found.") + return + + human_header("codeloom: Automated Agent Removal") + + for agent in to_remove: + uninstaller = agent_registry[agent] + if agent in ("claude", "opencode"): + # Uninstall scope default is usually 'all' or 'project' + scope = "all" if agent == "claude" else "project" + ctx.invoke(uninstaller, scope=scope) + else: + ctx.invoke(uninstaller) + + human_done("Removal complete! codeloom integrations cleaned up.") + + + +@cli.command() +@click.argument("source_dir", type=click.Path(exists=True)) +@click.option( + "--output", + "-o", + type=click.Path(), + default=None, + help="Output directory for the database", +) +@click.pass_context +def watch(ctx, source_dir: str, output: str | None): + """Watch for file changes and update the code graph in real-time. + + Uses the watchdog library to monitor the source directory and + triggers incremental builds on file save events. + """ + import time + + from watchdog.events import FileSystemEventHandler + from watchdog.observers import Observer + + from codeloom.core.pipeline import run_pipeline + + from ._helpers import human_header, human_ok + + source_path = Path(source_dir).resolve() + human_header(f"Watching {source_path} for changes...") + + class RebuildHandler(FileSystemEventHandler): + def __init__(self): + self.last_rebuild = 0 + self.cooldown = 1.0 # seconds + + def on_modified(self, event): + if event.is_directory: + return + # Check extension + from codeloom.core.detect import EXT_TO_LANG - ctx.invoke(claude_install, scope=scope) - ctx.invoke(opencode_install, scope=scope) + if Path(event.src_path).suffix.lower() not in EXT_TO_LANG: + return - # Project-level only integrations - aider_install() - cline_install() - codex_install() - cursor_install() - gemini_install() - windsurf_install() + now = time.time() + if now - self.last_rebuild < self.cooldown: + return - human_done("Setup complete! All detected agents are now codeloom-aware.") + msg = f"Change detected in {Path(event.src_path).name}. Updating..." + human_ok(msg) + try: + run_pipeline( + str(source_path), + output_dir=output, + incremental=True, + embed=True, + git=True, # Use git accelerator for speed + ) + self.last_rebuild = time.time() + except Exception as e: + click.echo(f" Error during update: {e}", err=True) + + event_handler = RebuildHandler() + observer = Observer() + observer.schedule(event_handler, str(source_path), recursive=True) + observer.start() + + try: + while True: + time.sleep(1) + except KeyboardInterrupt: + observer.stop() + observer.join() @@ -128,6 +302,11 @@ def setup(ctx, scope: str | None): type=click.Choice(["auto", "en", "multilingual"]), help="Language mode for text embeddings", ) +@click.option( + "--git", + is_flag=True, + help="Use git status to find changed files (accelerator)", +) @click.pass_context def build( ctx, @@ -137,6 +316,7 @@ def build( max_file_size: int, incremental: bool, lang: str, + git: bool, ): """Build code graph from a source directory.""" import sys @@ -156,6 +336,7 @@ def _progress(stage: str, detail: str = "") -> None: on_progress=_progress, incremental=incremental, lang=lang, + git=git, ) # Capture summary values before releasing memory diff --git a/codeloom/core/build.py b/codeloom/core/build.py index 39a207e..2ba311c 100644 --- a/codeloom/core/build.py +++ b/codeloom/core/build.py @@ -342,20 +342,35 @@ def compute_edge_weights( def compute_pagerank( G: nx.DiGraph, personalization: dict[str, float] | None = None, + initial_scores: dict[str, float] | None = None, ) -> dict[str, float]: """Compute PageRank importance scores for all nodes. Args: G: The code graph. personalization: Optional per-node bias (e.g., recency weighting). + initial_scores: Optional hot-start scores for faster convergence. Returns: Dict mapping node_id to importance score. """ if len(G) == 0: return {} + + # Filter initial scores to only include nodes present in the current graph + nstart = None + if initial_scores: + nstart = {n: s for n, s in initial_scores.items() if n in G} + if not nstart: + nstart = None + try: - return nx.pagerank(G, personalization=personalization, max_iter=200) + return nx.pagerank( + G, + personalization=personalization, + nstart=nstart, + max_iter=200 + ) except nx.PowerIterationFailedConvergence: return {n: 1.0 / len(G) for n in G} diff --git a/codeloom/core/detect.py b/codeloom/core/detect.py index 62e1da1..8298035 100644 --- a/codeloom/core/detect.py +++ b/codeloom/core/detect.py @@ -165,10 +165,39 @@ def _classify_file(path: Path) -> str: return "other" +def get_file_info(path: Path) -> DetectedFile | None: + """Return info for a single file, or None if it should be skipped.""" + if not path.is_file(): + return None + if _is_sensitive(path): + return None + try: + size = path.stat().st_size + except OSError: + return None + if size == 0: + return None + + ext = path.suffix.lower() + lang = EXT_TO_LANG.get(ext, "unknown") + file_type = _classify_file(path) + + if file_type == "other": + return None + + return DetectedFile( + path=path, + language=lang, + file_type=file_type, + size_bytes=size, + ) + + def detect( root: Path, ignore_patterns: set[str] | None = None, max_file_size: int = 1_000_000, # 1MB default + git: bool = False, ) -> DetectResult: """Scan directory tree and classify files. @@ -181,6 +210,7 @@ def detect( root: Root directory to scan. ignore_patterns: Additional glob patterns to ignore. max_file_size: Skip files larger than this (bytes). + git: Use git status to find changed files (accelerator). Returns: DetectResult with classified files and skip reasons. @@ -189,6 +219,25 @@ def detect( default_patterns = DEFAULT_IGNORE | (ignore_patterns or set()) result = DetectResult(root=root) + # 1. Git-powered delta discovery (Accelerator) + if git: + from .git import get_git_deltas + + deltas = get_git_deltas(root) + if deltas: + # For modified/added/renamed files, we need their info + files_to_check = set(deltas.modified + deltas.added) + for _, new_path in deltas.renamed: + files_to_check.add(new_path) + + for path in sorted(files_to_check): + info = get_file_info(path) + if info: + result.files.append(info) + return result + # Fall back to full scan if git failed or not a repo + + # 2. Traditional full scan # Load gitignore-spec matchers gitignore_spec = _load_gitignore_spec(root) codeloom_spec = _load_codeloom_ignore_spec(root) @@ -217,39 +266,19 @@ def detect( result.skipped.append(f"codeloom-ignored: {path}") continue - if _is_sensitive(path): - result.skipped.append(f"sensitive: {path}") - continue - - try: - size = path.stat().st_size - except OSError: - result.skipped.append(f"unreadable: {path}") - continue - - if size > max_file_size: - result.skipped.append(f"too_large ({size}B): {path}") - continue - - if size == 0: - result.skipped.append(f"empty: {path}") - continue - - ext = path.suffix.lower() - lang = EXT_TO_LANG.get(ext, "unknown") - file_type = _classify_file(path) - - if file_type == "other": - result.skipped.append(f"unsupported: {path}") - continue - - result.files.append( - DetectedFile( - path=path, - language=lang, - file_type=file_type, - size_bytes=size, - ) - ) + info = get_file_info(path) + if info: + if info.size_bytes > max_file_size: + result.skipped.append(f"too_large ({info.size_bytes}B): {path}") + else: + result.files.append(info) + else: + # Re-check sensitive or unsupported for logging skip reason + if _is_sensitive(path): + result.skipped.append(f"sensitive: {path}") + elif path.suffix.lower() not in EXT_TO_LANG: + result.skipped.append(f"unsupported: {path}") + elif path.stat().st_size == 0: + result.skipped.append(f"empty: {path}") return result diff --git a/codeloom/core/git.py b/codeloom/core/git.py new file mode 100644 index 0000000..b24ad5e --- /dev/null +++ b/codeloom/core/git.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import subprocess +from dataclasses import dataclass +from pathlib import Path + + +@dataclass +class GitDelta: + modified: list[Path] + added: list[Path] + deleted: list[Path] + renamed: list[tuple[Path, Path]] # (old, new) + +def get_git_deltas(repo_root: str | Path) -> GitDelta | None: + """Get file deltas since last index using git. + + Uses `git status --porcelain -u` and `git diff --name-status` to + identify changed, added, and deleted files. + """ + try: + # Check if it's a git repo + subprocess.check_output( + ["git", "rev-parse", "--is-inside-work-tree"], + cwd=repo_root, + stderr=subprocess.STDOUT, + ) + except (subprocess.CalledProcessError, FileNotFoundError): + return None + + modified, added, deleted, renamed = [], [], [], [] + + # Get status of staged and unstaged changes + # --porcelain=v1: XY path [-> path] + # X/Y are status codes: M=modified, A=added, D=deleted, R=renamed + try: + output = subprocess.check_output( + ["git", "status", "--porcelain", "-uall"], + cwd=repo_root, + encoding="utf-8", + ) + + for line in output.splitlines(): + if not line: + continue + status = line[:2] + path_part = line[3:] + + # Simplified status check + if "M" in status: + modified.append(Path(repo_root) / path_part) + elif "A" in status or "?" in status: + added.append(Path(repo_root) / path_part) + elif "D" in status: + deleted.append(Path(repo_root) / path_part) + elif "R" in status: + # Renames: "old -> new" + if " -> " in path_part: + old_path, new_path = path_part.split(" -> ", 1) + renamed.append( + (Path(repo_root) / old_path, Path(repo_root) / new_path) + ) + else: + # git status sometimes just shows the new name if not staged + modified.append(Path(repo_root) / path_part) + + return GitDelta( + modified=modified, added=added, deleted=deleted, renamed=renamed + ) + except Exception: + return None + diff --git a/codeloom/core/pipeline.py b/codeloom/core/pipeline.py index 7597e01..beb8b71 100644 --- a/codeloom/core/pipeline.py +++ b/codeloom/core/pipeline.py @@ -18,14 +18,11 @@ from codeloom.core.analyze import AnalysisResult, analyze from codeloom.core.build import ( build_graph, - compute_edge_weights, - compute_pagerank, merge_tier3_nodes, ) from codeloom.core.cluster import ClusterResult, hierarchical_cluster from codeloom.core.detect import DetectResult, detect from codeloom.core.extract import ExtractionResult -from codeloom.core.ts_extract import extract_file_ts as extract_file from codeloom.storage.store import KnowledgeStore logger = logging.getLogger(__name__) @@ -78,25 +75,28 @@ def run_pipeline( on_progress: callable | None = None, incremental: bool = False, lang: str = "auto", + git: bool = False, ) -> PipelineResult: - """Run the full code graph build pipeline. + """Execute the full code graph construction pipeline. Args: - source_dir: Directory to analyze. - output_dir: Where to store the database (default: source_dir/.codeloom). - embed: Whether to generate embeddings (requires sentence-transformers). - model_name: Sentence-transformers model name. - resolutions: Leiden resolution parameters for hierarchical clustering. - max_file_size: Skip files larger than this. - on_progress: Callback(stage: str, detail: str) for progress updates. + source_dir: Directory to scan. + output_dir: Directory to save the database. + embed: If true, generate vector embeddings. + model_name: Optional embedding model name. + max_file_size: Max file size in bytes. + on_progress: Progress callback. incremental: Skip unchanged files (based on content hash). - lang: Language mode — "auto" (detect from text nodes), "en" - (English-only models), or "multilingual" (force multilingual - text model). - - Returns: - PipelineResult with all intermediate and final results. + lang: Text embedding language mode. + git: Use git deltas for incremental builds. """ + + import networkx as nx + + from codeloom.core.extract import extract_file + + from .build import compute_edge_weights, compute_pagerank + source_dir = Path(source_dir).resolve() if output_dir is None: output_dir = source_dir / ".codeloom" @@ -125,11 +125,13 @@ def _end_stage(name: str) -> None: # Stage 1: Detect files _start_stage("detect") _progress("detect", f"Scanning {source_dir}") - result.detect_result = detect(source_dir, max_file_size=max_file_size) + result.detect_result = detect( + source_dir, max_file_size=max_file_size, git=git + ) _end_stage("detect") _progress("detect", f"Found {len(result.detect_result.files)} files") - if not result.detect_result.files: + if not result.detect_result.files and not git: store.set_meta("status", "empty") store.close() return result @@ -143,6 +145,18 @@ def _end_stage(name: str) -> None: # Load previous file hashes for incremental build prev_hashes: dict[str, str] = {} + deleted_files: list[str] = [] + + # Git renames (Phase 2 logic) + if git: + from .git import get_git_deltas + deltas = get_git_deltas(source_dir) + if deltas: + deleted_files = [str(f) for f in deltas.deleted] + # Phase 2 TODO: handle renamed_files to update path in DB + # without re-extracting. For now, modified/added are handled + # by detect() correctly + if incremental: raw = store.get_meta("file_hashes", "{}") try: @@ -150,9 +164,19 @@ def _end_stage(name: str) -> None: except (json.JSONDecodeError, TypeError): prev_hashes = {} + # Identify deleted files (only if not already found via git) + if not git: + current_files = {str(f.path) for f in result.detect_result.files} + deleted_files = [f for f in prev_hashes if f not in current_files] + + if deleted_files: + _progress("extract", f"Pruning {len(deleted_files)} deleted files") + store.prune_files(deleted_files) + new_hashes: dict[str, str] = {} skipped_count = 0 + for f in result.detect_result.files: try: fpath = str(f.path) @@ -248,7 +272,19 @@ def _end_stage(name: str) -> None: # Stage 4: PageRank _start_stage("pagerank") _progress("pagerank", "Computing importance scores") - result.pagerank = compute_pagerank(result.graph) + + # Hot-start PageRank for incremental builds + initial_scores = None + if incremental: + initial_scores = { + n: d.get("pagerank", 0.0) + for n, d in result.graph.nodes(data=True) + if "pagerank" in d + } + + result.pagerank = compute_pagerank( + result.graph, initial_scores=initial_scores + ) for node_id, score in result.pagerank.items(): if result.graph.has_node(node_id): result.graph.nodes[node_id]["pagerank"] = score @@ -418,6 +454,9 @@ def _end_stage(name: str) -> None: if new_hashes: # Merge with previous hashes (keep unchanged files) all_hashes = {**prev_hashes, **new_hashes} + # Remove deleted files from hashes + for df in deleted_files: + all_hashes.pop(df, None) store.set_meta("file_hashes", json.dumps(all_hashes)) # Build vector index diff --git a/codeloom/storage/store.py b/codeloom/storage/store.py index b83a1e8..a1c99b7 100644 --- a/codeloom/storage/store.py +++ b/codeloom/storage/store.py @@ -136,6 +136,71 @@ def _init_schema(self) -> None: exc_info=True, ) + def prune_files(self, file_paths: list[str]) -> int: + """Remove all nodes and edges associated with the given file paths. + + Returns: + Number of nodes removed. + """ + if not file_paths: + return 0 + + c = self.conn.cursor() + + # 1. Identify nodes to be removed + placeholders = ",".join("?" for _ in file_paths) + nodes = c.execute( + f"SELECT id FROM nodes WHERE file_path IN ({placeholders})", + file_paths + ).fetchall() + node_ids = [n["id"] for n in nodes] + + if not node_ids: + return 0 + + # 2. Delete nodes (cascading cleanup) + node_placeholders = ",".join("?" for _ in node_ids) + + # Delete from community_members + c.execute( + f"DELETE FROM community_members " + f"WHERE node_id IN ({node_placeholders})", + node_ids, + ) + + # Delete from embeddings + c.execute( + f"DELETE FROM embeddings WHERE node_id IN ({node_placeholders})", + node_ids, + ) + + # Delete from FTS5 index if present + if self._has_fts(): + c.execute( + f"DELETE FROM nodes_fts WHERE node_id IN ({node_placeholders})", + node_ids, + ) + + # Delete edges where this node is source or target + c.execute( + f"DELETE FROM edges WHERE source IN ({node_placeholders}) " + f"OR target IN ({node_placeholders})", + node_ids + node_ids, + ) + + # Finally delete nodes + c.execute( + f"DELETE FROM nodes WHERE id IN ({node_placeholders})", + node_ids, + ) + + self.conn.commit() + logger.info( + "Pruned %d nodes from %d files", len(node_ids), len(file_paths) + ) + return len(node_ids) + + # --- Graph persistence --- def save_graph(self, G: nx.DiGraph) -> None: diff --git a/docs/README_de.md b/docs/README_de.md index 199a138..7e371bf 100644 --- a/docs/README_de.md +++ b/docs/README_de.md @@ -14,6 +14,10 @@ Python 3.10+

+

+ codeloom visualization +

+ --- ## Warum codeloom? diff --git a/docs/README_ja.md b/docs/README_ja.md index 15d5c3e..e5c5174 100644 --- a/docs/README_ja.md +++ b/docs/README_ja.md @@ -14,6 +14,10 @@ Python 3.10+

+

+ codeloom visualization +

+ --- ## なぜ codeloom なのか? diff --git a/docs/README_ko.md b/docs/README_ko.md index a8ac78f..4aed280 100644 --- a/docs/README_ko.md +++ b/docs/README_ko.md @@ -14,6 +14,10 @@ Python 3.10+

+

+ codeloom visualization +

+ --- ## 왜 codeloom인가? diff --git a/docs/README_zh.md b/docs/README_zh.md index 4199f50..05e84dd 100644 --- a/docs/README_zh.md +++ b/docs/README_zh.md @@ -14,6 +14,10 @@ Python 3.10+

+

+ codeloom visualization +

+ --- ## 为什么选择 codeloom? diff --git a/docs/assets/codeloom.jpeg b/docs/assets/codeloom.jpeg new file mode 100644 index 0000000..575a74f Binary files /dev/null and b/docs/assets/codeloom.jpeg differ diff --git a/pyproject.toml b/pyproject.toml index 0862909..c4c5025 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "codeloom" -version = "0.1.2" +version = "0.1.3" description = "Local-first code graph builder with 5-signal hybrid search for AI coding agents" license = "MIT" requires-python = ">=3.10" diff --git a/tests/test_cli.py b/tests/test_cli.py index 124d4a2..3649096 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -699,7 +699,8 @@ def test_cline_install_idempotent(self, tmp_path): runner.invoke(cli, ["cline", "install"]) result = runner.invoke(cli, ["cline", "install"]) assert result.exit_code == 0 - assert "already" in result.output.lower() + # Smart update logic: second run sees it's up-to-date + assert "up-to-date" in result.output.lower() def test_cline_install_appends_to_existing(self, tmp_path): runner = CliRunner() @@ -884,7 +885,8 @@ def test_claude_install_idempotent(self, tmp_path): r2 = runner.invoke(cli, ["claude", "install", "--scope", "project"]) assert r1.exit_code == 0 assert r2.exit_code == 0 - assert "already" in r2.output.lower() + # Smart update logic: second run sees it's up-to-date + assert "up-to-date" in r2.output.lower() def test_claude_uninstall_project(self, tmp_path): runner = CliRunner() @@ -928,7 +930,8 @@ def test_codex_install_idempotent(self, tmp_path): r2 = runner.invoke(cli, ["codex", "install"]) assert r1.exit_code == 0 assert r2.exit_code == 0 - assert "already" in r2.output.lower() + # Smart update logic: second run sees it's up-to-date + assert "up-to-date" in r2.output.lower() def test_codex_uninstall(self, tmp_path): runner = CliRunner() @@ -970,7 +973,8 @@ def test_gemini_install_idempotent(self, tmp_path): r2 = runner.invoke(cli, ["gemini", "install"]) assert r1.exit_code == 0 assert r2.exit_code == 0 - assert "already" in r2.output.lower() + # Smart update logic: second run sees it's up-to-date + assert "up-to-date" in r2.output.lower() class TestCursorIntegration: @@ -990,7 +994,8 @@ def test_cursor_install_idempotent(self, tmp_path): r2 = runner.invoke(cli, ["cursor", "install"]) assert r1.exit_code == 0 assert r2.exit_code == 0 - assert "already" in r2.output.lower() + # Smart update logic: second run sees it's up-to-date + assert "up-to-date" in r2.output.lower() def test_cursor_uninstall(self, tmp_path): runner = CliRunner() @@ -1018,7 +1023,8 @@ def test_windsurf_install_idempotent(self, tmp_path): r2 = runner.invoke(cli, ["windsurf", "install"]) assert r1.exit_code == 0 assert r2.exit_code == 0 - assert "already" in r2.output.lower() + # Smart update logic: second run sees it's up-to-date + assert "up-to-date" in r2.output.lower() def test_windsurf_uninstall(self, tmp_path): runner = CliRunner() @@ -1046,7 +1052,8 @@ def test_aider_install_idempotent(self, tmp_path): r2 = runner.invoke(cli, ["aider", "install"]) assert r1.exit_code == 0 assert r2.exit_code == 0 - assert "already" in r2.output.lower() + # Smart update logic: second run sees it's up-to-date + assert "up-to-date" in r2.output.lower() def test_aider_uninstall(self, tmp_path): runner = CliRunner() diff --git a/tests/test_evolution.py b/tests/test_evolution.py new file mode 100644 index 0000000..89494e3 --- /dev/null +++ b/tests/test_evolution.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from codeloom.core.pipeline import run_pipeline +from codeloom.storage.store import KnowledgeStore + +class TestEvolution: + def test_pruning_on_deletion(self, tmp_path): + """Verify that deleting a file and running an incremental build prunes nodes.""" + src = tmp_path / "src" + src.mkdir() + + f1 = src / "file1.py" + f1.write_text("def func1(): pass") + + f2 = src / "file2.py" + f2.write_text("def func2(): pass") + + # 1. First build + out = tmp_path / "out" + run_pipeline(str(src), output_dir=str(out), embed=False, incremental=True) + + db_path = out / "knowledge.db" + store = KnowledgeStore(db_path) + G = store.load_graph() + + assert any(d.get("file_path") == str(f1) for _, d in G.nodes(data=True)) + assert any(d.get("file_path") == str(f2) for _, d in G.nodes(data=True)) + store.close() + + def test_state_identicality(self, tmp_path): + """Verify that incremental build is bit-for-bit identical to full build.""" + src = tmp_path / "src" + src.mkdir() + + f1 = src / "file1.py" + f1.write_text("def a(): pass") + + # 1. Initial build + out_inc = tmp_path / "out_inc" + run_pipeline(str(src), output_dir=str(out_inc), embed=False, incremental=True) + + # 2. Add file and run incremental + f2 = src / "file2.py" + f2.write_text("def b(): pass") + run_pipeline(str(src), output_dir=str(out_inc), embed=False, incremental=True) + + store_inc = KnowledgeStore(out_inc / "knowledge.db") + G_inc = store_inc.load_graph() + + # 3. Clean full build from the same source state + out_full = tmp_path / "out_full" + run_pipeline(str(src), output_dir=str(out_full), embed=False, incremental=False) + + store_full = KnowledgeStore(out_full / "knowledge.db") + G_full = store_full.load_graph() + + # 4. Compare + assert sorted(G_inc.nodes()) == sorted(G_full.nodes()) + assert G_inc.number_of_edges() == G_full.number_of_edges() + + store_inc.close() + store_full.close() + + # 5. Test pruning + f1.unlink() + run_pipeline(str(src), output_dir=str(out_inc), embed=False, incremental=True) + + store_inc_pruned = KnowledgeStore(out_inc / "knowledge.db") + G_inc_pruned = store_inc_pruned.load_graph() + + out_full_pruned = tmp_path / "out_full_pruned" + run_pipeline(str(src), output_dir=str(out_full_pruned), embed=False, incremental=False) + store_full_pruned = KnowledgeStore(out_full_pruned / "knowledge.db") + G_full_pruned = store_full_pruned.load_graph() + + assert sorted(G_inc_pruned.nodes()) == sorted(G_full_pruned.nodes()) + store_inc_pruned.close() + store_full_pruned.close() + + def test_pagerank_hot_start(self, tmp_path): + """Verify that incremental build uses nstart for PageRank.""" + from unittest.mock import patch + src = tmp_path / "src" + src.mkdir() + (src / "a.py").write_text("def a(): pass") + + out = tmp_path / "out" + # First build + run_pipeline(str(src), output_dir=str(out), embed=False, incremental=True) + + # Second build (incremental) - Mock nx.pagerank to capture nstart + with patch("networkx.pagerank") as mock_pr: + mock_pr.return_value = {"node": 1.0} + run_pipeline(str(src), output_dir=str(out), embed=False, incremental=True) + + # Check if nstart was passed + args, kwargs = mock_pr.call_args + assert "nstart" in kwargs + assert kwargs["nstart"] is not None + # Should contain nodes from first build + assert len(kwargs["nstart"]) > 0 + + def test_git_acceleration(self, tmp_path): + """Verify that --git flag correctly handles modifications and deletions.""" + import subprocess + src = tmp_path / "src" + src.mkdir() + + # Init git repo + subprocess.run(["git", "init"], cwd=src, check=True) + subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=src, check=True) + subprocess.run(["git", "config", "user.name", "Test"], cwd=src, check=True) + + f1 = src / "file1.py" + f1.write_text("def a(): pass") + + subprocess.run(["git", "add", "file1.py"], cwd=src, check=True) + subprocess.run(["git", "commit", "-m", "initial"], cwd=src, check=True) + + out = tmp_path / "out" + db_path = out / "knowledge.db" + # 1. First build (full) + run_pipeline(str(src), output_dir=str(out), embed=False, incremental=True) + + # 2. Modify file and add new one + f1.write_text("def a(): return 1") + f2 = src / "file2.py" + f2.write_text("def b(): pass") + # f2 is untracked + + # Build with --git + run_pipeline(str(src), output_dir=str(out), embed=False, incremental=True, git=True) + + store = KnowledgeStore(db_path) + G = store.load_graph() + + assert any("file1.py" in n for n in G.nodes) + assert any("file2.py" in n for n in G.nodes) # should be picked up as '?' in status + store.close() + + # 3. Delete file1 + f1.unlink() + # Note: git status will show it as deleted + + run_pipeline(str(src), output_dir=str(out), embed=False, incremental=True, git=True) + + store_p = KnowledgeStore(db_path) + G_pruned = store_p.load_graph() + assert not any("file1.py" in n for n in G_pruned.nodes) + assert any("file2.py" in n for n in G_pruned.nodes) + store_p.close() + + G = store.load_graph() + + # file1 nodes should be gone + assert not any(d.get("file_path") == str(f1) for _, d in G.nodes(data=True)) + # file2 nodes should remain + assert any(d.get("file_path") == str(f2) for _, d in G.nodes(data=True)) + + # Check metadata hashes + hashes = json.loads(store.get_meta("file_hashes")) + assert str(f1) not in hashes + assert str(f2) in hashes + + store.close() diff --git a/tests/test_intelligent_setup.py b/tests/test_intelligent_setup.py new file mode 100644 index 0000000..c1340d4 --- /dev/null +++ b/tests/test_intelligent_setup.py @@ -0,0 +1,124 @@ +import json +from pathlib import Path +from unittest.mock import patch +from click.testing import CliRunner +from codeloom.cli.main import cli + +def test_intelligent_detection_cursor(tmp_path): + """Verify that setup only detects agents whose footprint is present.""" + runner = CliRunner() + with runner.isolated_filesystem(temp_dir=tmp_path): + # 1. Mock only Cursor presence + Path(".cursor").mkdir() + + # We need to mock shutil.which and detect_agents to ensure isolation + with patch("shutil.which", return_value=None): + result = runner.invoke(cli, ["setup"], input="n\n") + + # Use lowercase for case-insensitive check + output = result.output.lower() + assert "detected agents:" in output + assert "cursor" in output + # Claude/Aider should not be detected if mocked correctly + assert "claude" not in output + assert "aider" not in output + +def test_unified_uninstall_footprint_scan(tmp_path): + """Verify that 'uninstall' (no args) detects and removes footprints.""" + runner = CliRunner() + with runner.isolated_filesystem(temp_dir=tmp_path): + # 1. Setup multiple + runner.invoke(cli, ["setup", "claude"], input="project\n") + runner.invoke(cli, ["setup", "cursor"]) + + # 2. Run uninstall, confirm all + result = runner.invoke(cli, ["uninstall"], input="y\n") + + output = result.output.lower() + assert "found codeloom in:" in output + assert "claude" in output + assert "cursor" in output + assert "removal complete!" in output + + # Footprints should be gone (or files cleaned) + assert not Path("CLAUDE.md").exists() + assert not Path(".cursor/rules/codeloom.mdc").exists() + +def test_setup_unexpected_argument_fix(tmp_path): + """Verify that setup doesn't fail with 'unexpected extra argument' anymore.""" + runner = CliRunner() + with runner.isolated_filesystem(temp_dir=tmp_path): + # Mock detect_agents to return empty list to trigger manual selection + with patch("codeloom.cli.integrations.detect_agents", return_value=[]): + # Pick 'aider' from the list + result = runner.invoke(cli, ["setup"], input="project\naider\n") + + assert result.exit_code == 0 + assert "Error: Got unexpected extra argument" not in result.output + assert "Setup complete!" in result.output + +def test_opencode_agents_md_sync(tmp_path): + """Verify that opencode install creates and cleans AGENTS.md.""" + runner = CliRunner() + with runner.isolated_filesystem(temp_dir=tmp_path): + # 1. Install + runner.invoke(cli, ["opencode", "install", "--scope", "project"]) + assert Path("AGENTS.md").exists() + assert "codeloom" in Path("AGENTS.md").read_text() + + # 2. Uninstall + runner.invoke(cli, ["opencode", "uninstall", "--scope", "project"]) + # Should be deleted if it only had codeloom content + assert not Path("AGENTS.md").exists() + +def test_surgical_uninstall_integrity(tmp_path): + """Verify that uninstall only removes codeloom block, leaving notes.""" + runner = CliRunner() + with runner.isolated_filesystem(temp_dir=tmp_path): + notes = "# Project Notes\nCustom content." + Path("CLAUDE.md").write_text(notes) + + # 1. Setup + runner.invoke(cli, ["claude", "install", "--scope", "project"]) + + # 2. Uninstall + runner.invoke(cli, ["claude", "uninstall", "--scope", "project"]) + + # 3. Verify notes are back to original + assert Path("CLAUDE.md").read_text().strip() == notes.strip() + +def test_command_group_uniqueness(): + """Programmatically verify that command groups are not duplicated.""" + # This checks for the NameError/duplication issue + from codeloom.cli.main import cli + commands = list(cli.commands.keys()) + # The registration happens at module load time, + # so we check the actual click object + assert "cline" in commands + assert "aider" in commands + assert "opencode" in commands + + # Check if groups have correct subcommands + cline_group = cli.commands["cline"] + assert "install" in cline_group.commands + assert "uninstall" in cline_group.commands + +def test_json_merge_safety(tmp_path): + """Verify JSON configuration remains valid and uncorrupted.""" + runner = CliRunner() + with runner.isolated_filesystem(temp_dir=tmp_path): + settings_file = Path(".claude/settings.json") + settings_file.parent.mkdir() + + # 1. Create invalid JSON + settings_file.write_text("{ invalid json }") + result = runner.invoke(cli, ["claude", "install", "--scope", "project"]) + assert "Could not parse" in result.output + + # 2. Valid JSON with existing content + settings_file.write_text('{"existing": true}') + runner.invoke(cli, ["claude", "install", "--scope", "project"]) + + data = json.loads(settings_file.read_text()) + assert data["existing"] is True + assert "hooks" in data diff --git a/tests/test_smart_setup.py b/tests/test_smart_setup.py new file mode 100644 index 0000000..deb0f10 --- /dev/null +++ b/tests/test_smart_setup.py @@ -0,0 +1,112 @@ +import json +import re +from pathlib import Path +from click.testing import CliRunner +from codeloom.cli.main import cli +from codeloom.cli.integrations import get_codeloom_context + +def test_surgical_update(tmp_path): + """Verify that only the codeloom block is updated, leaving other notes intact.""" + runner = CliRunner() + with runner.isolated_filesystem(temp_dir=tmp_path): + # Create a file with old rules and custom notes + initial_content = ( + "\n" + "## codeloom\n\n" + "Old rules here.\n" + "\n\n" + "# Project Notes\n" + "Do not delete this." + ) + Path("CLAUDE.md").write_text(initial_content) + + result = runner.invoke(cli, ["claude", "install", "--scope", "project"]) + assert result.exit_code == 0 + + updated_content = Path("CLAUDE.md").read_text() + assert "# Project Notes" in updated_content + assert "Do not delete this." in updated_content + assert get_codeloom_context().strip() in updated_content + assert "Old rules here." not in updated_content + +def test_positional_enforcement(tmp_path): + """Verify that codeloom block is moved to the top if found elsewhere.""" + runner = CliRunner() + with runner.isolated_filesystem(temp_dir=tmp_path): + initial_content = ( + "# Top Notes\n" + "Keep me below rules.\n\n" + "\n" + "## codeloom\n" + "Rules at bottom.\n" + "" + ) + Path("CLAUDE.md").write_text(initial_content) + + runner.invoke(cli, ["claude", "install", "--scope", "project"]) + + content = Path("CLAUDE.md").read_text() + assert content.startswith("") + assert "# Top Notes" in content.split("")[1] + +def test_legacy_support(tmp_path): + """Verify that legacy unmarked headers are preserved while adding a marked block.""" + runner = CliRunner() + with runner.isolated_filesystem(temp_dir=tmp_path): + initial_content = "## codeloom\nLegacy unmarked rules." + Path("CLAUDE.md").write_text(initial_content) + + runner.invoke(cli, ["claude", "install", "--scope", "project"]) + + content = Path("CLAUDE.md").read_text() + assert content.startswith("") + assert "Legacy unmarked rules." in content + +def test_json_merge_idempotency(tmp_path): + """Verify that JSON merging doesn't duplicate hooks and preserves existing config.""" + runner = CliRunner() + with runner.isolated_filesystem(temp_dir=tmp_path): + # Existing config with another tool + existing = { + "hooks": { + "PreToolUse": [ + {"matcher": "Linter", "hooks": [{"type": "cmd", "command": "lint"}]} + ] + } + } + settings_file = Path(".claude/settings.json") + settings_file.parent.mkdir(parents=True) + settings_file.write_text(json.dumps(existing)) + + # Run twice + runner.invoke(cli, ["claude", "install", "--scope", "project"]) + runner.invoke(cli, ["claude", "install", "--scope", "project"]) + + data = json.loads(settings_file.read_text()) + hooks = data["hooks"]["PreToolUse"] + + # Should have 2 hooks total (linter + codeloom), not 3 + assert len(hooks) == 2 + assert any("Linter" in h["matcher"] for h in hooks) + assert any("codeloom" in json.dumps(h) for h in hooks) + +def test_skill_safety(tmp_path): + """Verify that edited skill files are not overwritten without --force.""" + runner = CliRunner() + with runner.isolated_filesystem(temp_dir=tmp_path): + # 1. Install official version + runner.invoke(cli, ["claude", "install", "--scope", "project"]) + skill_file = Path(".claude/skills/codeloom/SKILL.md") + + # 2. Manually edit it + skill_file.write_text("User edit") + + # 3. Try to update without force + result = runner.invoke(cli, ["claude", "install", "--scope", "project"]) + assert "manual edits" in result.output + assert skill_file.read_text() == "User edit" + + # 4. Update with force + result = runner.invoke(cli, ["claude", "install", "--scope", "project", "--force"]) + assert "Force-updated skill" in result.output + assert "User edit" not in skill_file.read_text() diff --git a/uv.lock b/uv.lock index ab7a3ac..826723e 100644 --- a/uv.lock +++ b/uv.lock @@ -477,438 +477,9 @@ wheels = [ ] [[package]] -name = "hedwig-cg" -version = "0.14.0" -source = { editable = "." } -dependencies = [ - { name = "click" }, - { name = "faiss-cpu" }, - { name = "igraph" }, - { name = "leidenalg" }, - { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "pathspec" }, - { name = "python-hcl2" }, - { name = "pyyaml" }, - { name = "rich" }, - { name = "sentence-transformers" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, - { name = "tree-sitter" }, - { name = "tree-sitter-c" }, - { name = "tree-sitter-c-sharp" }, - { name = "tree-sitter-cpp" }, - { name = "tree-sitter-elixir" }, - { name = "tree-sitter-go" }, - { name = "tree-sitter-java" }, - { name = "tree-sitter-javascript" }, - { name = "tree-sitter-kotlin" }, - { name = "tree-sitter-lua" }, - { name = "tree-sitter-objc" }, - { name = "tree-sitter-php" }, - { name = "tree-sitter-python" }, - { name = "tree-sitter-ruby" }, - { name = "tree-sitter-rust" }, - { name = "tree-sitter-scala" }, - { name = "tree-sitter-swift" }, - { name = "tree-sitter-typescript" }, -] - -[package.optional-dependencies] -dev = [ - { name = "mcp" }, - { name = "pytest" }, - { name = "pytest-cov" }, - { name = "ruff" }, -] -docs = [ - { name = "pymupdf" }, -] -mcp = [ - { name = "mcp" }, -] - -[package.metadata] -requires-dist = [ - { name = "click", specifier = ">=8.1" }, - { name = "faiss-cpu", specifier = ">=1.7.4" }, - { name = "igraph", specifier = ">=0.11" }, - { name = "leidenalg", specifier = ">=0.10" }, - { name = "mcp", marker = "extra == 'dev'", specifier = ">=1.0" }, - { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.0" }, - { name = "networkx", specifier = ">=3.2" }, - { name = "numpy", specifier = ">=1.26" }, - { name = "pathspec", specifier = ">=0.12" }, - { name = "pymupdf", marker = "extra == 'docs'", specifier = ">=1.24" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, - { name = "pytest-cov", marker = "extra == 'dev'" }, - { name = "python-hcl2", specifier = ">=4.3" }, - { name = "pyyaml", specifier = ">=6.0" }, - { name = "rich", specifier = ">=13.0" }, - { name = "ruff", marker = "extra == 'dev'" }, - { name = "sentence-transformers", specifier = ">=3.0" }, - { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0" }, - { name = "tree-sitter", specifier = ">=0.24" }, - { name = "tree-sitter-c", specifier = ">=0.23" }, - { name = "tree-sitter-c-sharp", specifier = ">=0.23" }, - { name = "tree-sitter-cpp", specifier = ">=0.23" }, - { name = "tree-sitter-elixir", specifier = ">=0.3" }, - { name = "tree-sitter-go", specifier = ">=0.23" }, - { name = "tree-sitter-java", specifier = ">=0.23" }, - { name = "tree-sitter-javascript", specifier = ">=0.23" }, - { name = "tree-sitter-kotlin", specifier = ">=1.0" }, - { name = "tree-sitter-lua", specifier = ">=0.5" }, - { name = "tree-sitter-objc", specifier = ">=3.0" }, - { name = "tree-sitter-php", specifier = ">=0.23" }, - { name = "tree-sitter-python", specifier = ">=0.23" }, - { name = "tree-sitter-ruby", specifier = ">=0.23" }, - { name = "tree-sitter-rust", specifier = ">=0.23" }, - { name = "tree-sitter-scala", specifier = ">=0.23" }, - { name = "tree-sitter-swift", specifier = ">=0.0.1" }, - { name = "tree-sitter-typescript", specifier = ">=0.23" }, -] -provides-extras = ["dev", "docs", "mcp"] - -[[package]] -name = "hf-xet" -version = "1.4.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/92/ec9ad04d0b5728dca387a45af7bc98fbb0d73b2118759f5f6038b61a57e8/hf_xet-1.4.3.tar.gz", hash = "sha256:8ddedb73c8c08928c793df2f3401ec26f95be7f7e516a7bee2fbb546f6676113", size = 670477, upload_time = "2026-03-31T22:40:07.874Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/72/43/724d307b34e353da0abd476e02f72f735cdd2bc86082dee1b32ea0bfee1d/hf_xet-1.4.3-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:7551659ba4f1e1074e9623996f28c3873682530aee0a846b7f2f066239228144", size = 3800935, upload_time = "2026-03-31T22:39:49.618Z" }, - { url = "https://files.pythonhosted.org/packages/2b/d2/8bee5996b699262edb87dbb54118d287c0e1b2fc78af7cdc41857ba5e3c4/hf_xet-1.4.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:bee693ada985e7045997f05f081d0e12c4c08bd7626dc397f8a7c487e6c04f7f", size = 3558942, upload_time = "2026-03-31T22:39:47.938Z" }, - { url = "https://files.pythonhosted.org/packages/c3/a1/e993d09cbe251196fb60812b09a58901c468127b7259d2bf0f68bf6088eb/hf_xet-1.4.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:21644b404bb0100fe3857892f752c4d09642586fd988e61501c95bbf44b393a3", size = 4207657, upload_time = "2026-03-31T22:39:39.69Z" }, - { url = "https://files.pythonhosted.org/packages/64/44/9eb6d21e5c34c63e5e399803a6932fa983cabdf47c0ecbcfe7ea97684b8c/hf_xet-1.4.3-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:987f09cfe418237812896a6736b81b1af02a3a6dcb4b4944425c4c4fca7a7cf8", size = 3986765, upload_time = "2026-03-31T22:39:37.936Z" }, - { url = "https://files.pythonhosted.org/packages/ea/7b/8ad6f16fdb82f5f7284a34b5ec48645bd575bdcd2f6f0d1644775909c486/hf_xet-1.4.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:60cf7fc43a99da0a853345cf86d23738c03983ee5249613a6305d3e57a5dca74", size = 4188162, upload_time = "2026-03-31T22:39:58.382Z" }, - { url = "https://files.pythonhosted.org/packages/1b/c4/39d6e136cbeea9ca5a23aad4b33024319222adbdc059ebcda5fc7d9d5ff4/hf_xet-1.4.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2815a49a7a59f3e2edf0cf113ae88e8cb2ca2a221bf353fb60c609584f4884d4", size = 4424525, upload_time = "2026-03-31T22:40:00.225Z" }, - { url = "https://files.pythonhosted.org/packages/46/f2/adc32dae6bdbc367853118b9878139ac869419a4ae7ba07185dc31251b76/hf_xet-1.4.3-cp313-cp313t-win_amd64.whl", hash = "sha256:42ee323265f1e6a81b0e11094564fb7f7e0ec75b5105ffd91ae63f403a11931b", size = 3671610, upload_time = "2026-03-31T22:40:10.42Z" }, - { url = "https://files.pythonhosted.org/packages/e2/19/25d897dcc3f81953e0c2cde9ec186c7a0fee413eb0c9a7a9130d87d94d3a/hf_xet-1.4.3-cp313-cp313t-win_arm64.whl", hash = "sha256:27c976ba60079fb8217f485b9c5c7fcd21c90b0367753805f87cb9f3cdc4418a", size = 3528529, upload_time = "2026-03-31T22:40:09.106Z" }, - { url = "https://files.pythonhosted.org/packages/ec/36/3e8f85ca9fe09b8de2b2e10c63b3b3353d7dda88a0b3d426dffbe7b8313b/hf_xet-1.4.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:5251d5ece3a81815bae9abab41cf7ddb7bcb8f56411bce0827f4a3071c92fdc6", size = 3801019, upload_time = "2026-03-31T22:39:56.651Z" }, - { url = "https://files.pythonhosted.org/packages/b5/9c/defb6cb1de28bccb7bd8d95f6e60f72a3d3fa4cb3d0329c26fb9a488bfe7/hf_xet-1.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1feb0f3abeacee143367c326a128a2e2b60868ec12a36c225afb1d6c5a05e6d2", size = 3558746, upload_time = "2026-03-31T22:39:54.766Z" }, - { url = "https://files.pythonhosted.org/packages/c1/bd/8d001191893178ff8e826e46ad5299446e62b93cd164e17b0ffea08832ec/hf_xet-1.4.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8b301fc150290ca90b4fccd079829b84bb4786747584ae08b94b4577d82fb791", size = 4207692, upload_time = "2026-03-31T22:39:46.246Z" }, - { url = "https://files.pythonhosted.org/packages/ce/48/6790b402803250e9936435613d3a78b9aaeee7973439f0918848dde58309/hf_xet-1.4.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d972fbe95ddc0d3c0fc49b31a8a69f47db35c1e3699bf316421705741aab6653", size = 3986281, upload_time = "2026-03-31T22:39:44.648Z" }, - { url = "https://files.pythonhosted.org/packages/51/56/ea62552fe53db652a9099eda600b032d75554d0e86c12a73824bfedef88b/hf_xet-1.4.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c5b48db1ee344a805a1b9bd2cda9b6b65fe77ed3787bd6e87ad5521141d317cd", size = 4187414, upload_time = "2026-03-31T22:40:04.951Z" }, - { url = "https://files.pythonhosted.org/packages/7d/f5/bc1456d4638061bea997e6d2db60a1a613d7b200e0755965ec312dc1ef79/hf_xet-1.4.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:22bdc1f5fb8b15bf2831440b91d1c9bbceeb7e10c81a12e8d75889996a5c9da8", size = 4424368, upload_time = "2026-03-31T22:40:06.347Z" }, - { url = "https://files.pythonhosted.org/packages/e4/76/ab597bae87e1f06d18d3ecb8ed7f0d3c9a37037fc32ce76233d369273c64/hf_xet-1.4.3-cp314-cp314t-win_amd64.whl", hash = "sha256:0392c79b7cf48418cd61478c1a925246cf10639f4cd9d94368d8ca1e8df9ea07", size = 3672280, upload_time = "2026-03-31T22:40:16.401Z" }, - { url = "https://files.pythonhosted.org/packages/62/05/2e462d34e23a09a74d73785dbed71cc5dbad82a72eee2ad60a72a554155d/hf_xet-1.4.3-cp314-cp314t-win_arm64.whl", hash = "sha256:681c92a07796325778a79d76c67011764ecc9042a8c3579332b61b63ae512075", size = 3528945, upload_time = "2026-03-31T22:40:14.995Z" }, - { url = "https://files.pythonhosted.org/packages/ac/9f/9c23e4a447b8f83120798f9279d0297a4d1360bdbf59ef49ebec78fe2545/hf_xet-1.4.3-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d0da85329eaf196e03e90b84c2d0aca53bd4573d097a75f99609e80775f98025", size = 3805048, upload_time = "2026-03-31T22:39:53.105Z" }, - { url = "https://files.pythonhosted.org/packages/0b/f8/7aacb8e5f4a7899d39c787b5984e912e6c18b11be136ef13947d7a66d265/hf_xet-1.4.3-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:e23717ce4186b265f69afa66e6f0069fe7efbf331546f5c313d00e123dc84583", size = 3562178, upload_time = "2026-03-31T22:39:51.295Z" }, - { url = "https://files.pythonhosted.org/packages/df/9a/a24b26dc8a65f0ecc0fe5be981a19e61e7ca963b85e062c083f3a9100529/hf_xet-1.4.3-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc360b70c815bf340ed56c7b8c63aacf11762a4b099b2fe2c9bd6d6068668c08", size = 4212320, upload_time = "2026-03-31T22:39:42.922Z" }, - { url = "https://files.pythonhosted.org/packages/53/60/46d493db155d2ee2801b71fb1b0fd67696359047fdd8caee2c914cc50c79/hf_xet-1.4.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:39f2d2e9654cd9b4319885733993807aab6de9dfbd34c42f0b78338d6617421f", size = 3991546, upload_time = "2026-03-31T22:39:41.335Z" }, - { url = "https://files.pythonhosted.org/packages/bc/f5/067363e1c96c6b17256910830d1b54099d06287e10f4ec6ec4e7e08371fc/hf_xet-1.4.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:49ad8a8cead2b56051aa84d7fce3e1335efe68df3cf6c058f22a65513885baac", size = 4193200, upload_time = "2026-03-31T22:40:01.936Z" }, - { url = "https://files.pythonhosted.org/packages/42/4b/53951592882d9c23080c7644542fda34a3813104e9e11fa1a7d82d419cb8/hf_xet-1.4.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7716d62015477a70ea272d2d68cd7cad140f61c52ee452e133e139abfe2c17ba", size = 4429392, upload_time = "2026-03-31T22:40:03.492Z" }, - { url = "https://files.pythonhosted.org/packages/8a/21/75a6c175b4e79662ad8e62f46a40ce341d8d6b206b06b4320d07d55b188c/hf_xet-1.4.3-cp37-abi3-win_amd64.whl", hash = "sha256:6b591fcad34e272a5b02607485e4f2a1334aebf1bc6d16ce8eb1eb8978ac2021", size = 3677359, upload_time = "2026-03-31T22:40:13.619Z" }, - { url = "https://files.pythonhosted.org/packages/8a/7c/44314ecd0e89f8b2b51c9d9e5e7a60a9c1c82024ac471d415860557d3cd8/hf_xet-1.4.3-cp37-abi3-win_arm64.whl", hash = "sha256:7c2c7e20bcfcc946dc67187c203463f5e932e395845d098cc2a93f5b67ca0b47", size = 3533664, upload_time = "2026-03-31T22:40:12.152Z" }, -] - -[[package]] -name = "httpcore" -version = "1.0.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload_time = "2025-04-24T22:06:22.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload_time = "2025-04-24T22:06:20.566Z" }, -] - -[[package]] -name = "httpx" -version = "0.28.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload_time = "2024-12-06T15:37:23.222Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload_time = "2024-12-06T15:37:21.509Z" }, -] - -[[package]] -name = "httpx-sse" -version = "0.4.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload_time = "2025-10-10T21:48:22.271Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload_time = "2025-10-10T21:48:21.158Z" }, -] - -[[package]] -name = "huggingface-hub" -version = "1.10.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "filelock" }, - { name = "fsspec" }, - { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, - { name = "httpx" }, - { name = "packaging" }, - { name = "pyyaml" }, - { name = "tqdm" }, - { name = "typer" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0c/4d/00734890c7fcfe2c7ff04f1c1a167186c42b19e370a2dd8cfd8c34fc92c4/huggingface_hub-1.10.2.tar.gz", hash = "sha256:4b276f820483b709dc86a53bcb8183ea496b8d8447c9f7f88a115a12b498a95f", size = 758428, upload_time = "2026-04-14T10:42:28.498Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/c9/4c1e1216b24bcab140c83acdf8bc89a846ea17cd8a06cd18e3fd308a297f/huggingface_hub-1.10.2-py3-none-any.whl", hash = "sha256:c26c908767cc711493978dc0b4f5747ba7841602997cc98bfd628450a28cf9bc", size = 642581, upload_time = "2026-04-14T10:42:26.563Z" }, -] - -[[package]] -name = "idna" -version = "3.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload_time = "2025-10-12T14:55:20.501Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload_time = "2025-10-12T14:55:18.883Z" }, -] - -[[package]] -name = "igraph" -version = "1.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "texttable" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/23/be/56bef1919005b4caf1f71522b300d359f7faeb7ae93a3b0baa9b4f146a87/igraph-1.0.0.tar.gz", hash = "sha256:2414d0be2e4d77ee5357807d100974b40f6082bb1bb71988ec46cfb6728651ee", size = 5077105, upload_time = "2025-10-23T12:22:50.127Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/03/3278ad0ceb3ea0e84d8ae3a85bdded4d0e57853aeb802a200feb43847b93/igraph-1.0.0-cp39-abi3-macosx_10_15_x86_64.whl", hash = "sha256:c2cbc415e02523e5a241eecee82319080bf928a70b1ba299f3b3e25bf029b6d4", size = 2257415, upload_time = "2025-10-23T12:22:27.246Z" }, - { url = "https://files.pythonhosted.org/packages/0d/bc/6281ec7f9baaf71ee57c3b1748da2d3148d15d253e1a03006f204aa68ca5/igraph-1.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a27753cd80680a8f676c2d5a467aaa4a95e510b30748398ec4e4aeb982130e8", size = 2048555, upload_time = "2025-10-23T12:22:29.49Z" }, - { url = "https://files.pythonhosted.org/packages/2a/38/3cd6428a4ed4c09a56df05998438e7774fd1d799ee4fb8fc481674f5f7fc/igraph-1.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a55dc3a2a4e3fc3eba42479910c1511bfc3ecb33cdf5f0406891fd85f14b5aee", size = 5314141, upload_time = "2025-10-23T12:22:31.023Z" }, - { url = "https://files.pythonhosted.org/packages/7d/da/dd2867c25adbb41563720f14b5fc895c98bf88be682a3faff4f7b3118d2a/igraph-1.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2d04c2c76f686fb1f554ee35dfd3085f5e73b7965ba6b4cf06d53e66b1955522", size = 5683134, upload_time = "2025-10-23T12:22:32.423Z" }, - { url = "https://files.pythonhosted.org/packages/e5/40/243c118d34ab80382d7009c4dcb99b887384c3d2ce84d29eeac19e2a007a/igraph-1.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2b52dc1757fff0fed29a9f7a276d971a11db4211569ed78b9eab36288dfcc9d", size = 6211583, upload_time = "2025-10-23T12:22:34.238Z" }, - { url = "https://files.pythonhosted.org/packages/1d/b7/88f433819c54b496cb0315fce28e658970cb20ff5dbd52a5a605ce2888de/igraph-1.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:05c79a2a8fca695b2f217a6fa7f2549f896f757d4db41be32a055400cb19cc30", size = 6594509, upload_time = "2025-10-23T12:22:35.831Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5d/8f7f6f619d374e959aa3664ebc4b24c10abc90c2e8efbed97f2623fadaf5/igraph-1.0.0-cp39-abi3-win32.whl", hash = "sha256:c2bce3cd472fec3dd9c4d8a3ea5b6b9be65fb30edf760beb4850760dd4f2d479", size = 2725406, upload_time = "2025-10-23T12:22:37.588Z" }, - { url = "https://files.pythonhosted.org/packages/af/77/a85b3745cf40a0572bae2de8cd9c2a2a8af78e5cf3e880fc0a249114e609/igraph-1.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:faeff8ede0cf15eb4ded44b0fcea6e1886740146e60504c24ad2da14e0939563", size = 3221663, upload_time = "2025-10-23T12:22:39.404Z" }, - { url = "https://files.pythonhosted.org/packages/ef/7e/5df541c37bdf6493035e89c22bd53f30d99b291bcda6c78e9a8afeecec2b/igraph-1.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:b607cafc24b10a615e713ee96e58208ef27e0764af80140c7cc45d4724a3f2df", size = 2785701, upload_time = "2025-10-23T12:22:41.03Z" }, - { url = "https://files.pythonhosted.org/packages/b9/73/bf1d4dbbc9123435b3ca14bb608b243a50a4f158ecea564bf196715248d9/igraph-1.0.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3189c1a8e8a8f58009f3f729040eb3701254d074ed37245691d529869ec940c5", size = 2246636, upload_time = "2025-10-23T12:22:42.314Z" }, - { url = "https://files.pythonhosted.org/packages/59/ac/28482f2af45cc0a0ca88a69d17a6ea694f58bdbd22cc876e7273a0379282/igraph-1.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ebe9502689b946301584b3cfacdbc70c58c4d664d804e39b6daa31be5c20bf46", size = 2036101, upload_time = "2025-10-23T12:22:43.957Z" }, - { url = "https://files.pythonhosted.org/packages/56/80/806a093df1d1ddc3b30d0418b1ee56388ae7018f8ae288677ee2b3a1abaf/igraph-1.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:f117683108c54330d6dc67a708e3724c13c9989885122a29781296872989a222", size = 3053403, upload_time = "2025-10-23T12:22:45.573Z" }, - { url = "https://files.pythonhosted.org/packages/56/bf/cf7aeff230a4368c0b8bc6b02f3ea27db41db33714b51e1e8a7c1458f31b/igraph-1.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:077dbff0edb8b4ce0f9fefdf325200346d9d5db02de31872b41743de08e67a16", size = 3262472, upload_time = "2025-10-23T12:22:47.248Z" }, - { url = "https://files.pythonhosted.org/packages/d8/ca/dbc06072d5eea402a6dc81f387afb1b7e0c415f1d8a75232943fc4d1bfdb/igraph-1.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:fe7c693b2a84a4e03ca31e65aa05a2ecd8728137fa9909ccbf6453b4200b856d", size = 3218861, upload_time = "2025-10-23T12:22:48.46Z" }, -] - -[[package]] -name = "iniconfig" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload_time = "2025-10-18T21:55:43.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload_time = "2025-10-18T21:55:41.639Z" }, -] - -[[package]] -name = "jinja2" -version = "3.1.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markupsafe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload_time = "2025-03-05T20:05:02.478Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload_time = "2025-03-05T20:05:00.369Z" }, -] +name = "codeloom" +version = "0.1.3" -[[package]] -name = "joblib" -version = "1.5.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload_time = "2025-12-15T08:41:46.427Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload_time = "2025-12-15T08:41:44.973Z" }, -] - -[[package]] -name = "jsonschema" -version = "4.26.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "jsonschema-specifications" }, - { name = "referencing" }, - { name = "rpds-py" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload_time = "2026-01-07T13:41:07.246Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload_time = "2026-01-07T13:41:05.306Z" }, -] - -[[package]] -name = "jsonschema-specifications" -version = "2025.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "referencing" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload_time = "2025-09-08T01:34:59.186Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload_time = "2025-09-08T01:34:57.871Z" }, -] - -[[package]] -name = "lark" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/da/34/28fff3ab31ccff1fd4f6c7c7b0ceb2b6968d8ea4950663eadcb5720591a0/lark-1.3.1.tar.gz", hash = "sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905", size = 382732, upload_time = "2025-10-27T18:25:56.653Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151, upload_time = "2025-10-27T18:25:54.882Z" }, -] - -[[package]] -name = "leidenalg" -version = "0.11.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "igraph" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5d/a5/853e93441aed7f82b0389f86f37e19413e817ba0c54cc790895935256968/leidenalg-0.11.0.tar.gz", hash = "sha256:f454be96bbc8089ea2a90ca853d8d389ab646de964a03bd58417f8b29ff8ef5d", size = 452850, upload_time = "2025-10-31T17:14:48.684Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/28/a9/4ab4e244215db0c8b626e4bed0d3e0fbd191c52d2d5f5cb9d160139ecc7e/leidenalg-0.11.0-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:5607589050bfc1926e657b4d8a3b5341fe1eb81018c22cf4a3d3a39e368d1fcb", size = 2256514, upload_time = "2025-10-31T17:14:27.574Z" }, - { url = "https://files.pythonhosted.org/packages/98/f4/98db342d603671ae0a233f0a624939a47161044a2716cbd62a50440a1132/leidenalg-0.11.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:9b5781876b1f1faed72a4f9926ff52de286843556b9d6791fe25a2acb33b7a5c", size = 1926003, upload_time = "2025-10-31T17:14:29.521Z" }, - { url = "https://files.pythonhosted.org/packages/9b/38/fd6ac21af10b12828b472eada4fce0edf2a212581238ad0c8d1afebc6f98/leidenalg-0.11.0-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a80f49477f8e793f27d8e08949177f19e3834cd878af50a662b4f87335d06549", size = 2545535, upload_time = "2025-10-31T17:14:31.102Z" }, - { url = "https://files.pythonhosted.org/packages/77/87/b087584750a788535b4a8d56ddeb82a175d32b472aa5338a4e2cc593a42c/leidenalg-0.11.0-cp38-abi3-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:2143be3e80485584ccbdf927323fce65345da17facd0f8b438f11015f5dc6c27", size = 2845029, upload_time = "2025-10-31T17:14:32.815Z" }, - { url = "https://files.pythonhosted.org/packages/b0/a4/a89e2ce16a580f7bea066ed49364f0b3e04a6412f0c3692975bee8515141/leidenalg-0.11.0-cp38-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:571a0934f831a69442d82889d319bdba93de924bd9e09b720cd8cbe6fdc08c17", size = 2738084, upload_time = "2025-10-31T17:14:35.246Z" }, - { url = "https://files.pythonhosted.org/packages/e8/fe/8923cac6cd7c9e0ac5f38aaa69a4744c93d025575763d05f7a3baae8020d/leidenalg-0.11.0-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:aec03e7178b19102dd271b453a39b9865cf283b4113151ba60514e5681046294", size = 4070307, upload_time = "2025-10-31T17:14:36.796Z" }, - { url = "https://files.pythonhosted.org/packages/fe/94/beaab5ee9968f9389f705532c31ffb868bad8a5ce68fb699ddde5ddc5409/leidenalg-0.11.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:310b9269a11fd1e960590c1a2b6ff685a2cc42aa3234ce67bc2a623ab61f26a9", size = 3797863, upload_time = "2025-10-31T17:14:38.124Z" }, - { url = "https://files.pythonhosted.org/packages/6e/8e/8caf4ba38fd7d8e6197348b348a4ab666b1b3117225ea2f0934a98a93176/leidenalg-0.11.0-cp38-abi3-win32.whl", hash = "sha256:5ea4cd7ee054540112b28f7e2d64658dcccd59f61a5d6a08a41df808645f96e9", size = 1643351, upload_time = "2025-10-31T17:14:39.385Z" }, - { url = "https://files.pythonhosted.org/packages/47/15/7d459a8e2a43f17c1db129b997b7bb7aa7f000a0967bab87c28b8c5cf448/leidenalg-0.11.0-cp38-abi3-win_amd64.whl", hash = "sha256:5e789c0960008d185413344a402d0587580c441644d4d20bf57c96f25d4d1710", size = 1990321, upload_time = "2025-10-31T17:14:40.892Z" }, - { url = "https://files.pythonhosted.org/packages/b2/01/f6bdfb489ce86cb11f7b5428bb8c3cff751436fff4e12c99f3b5f59d622e/leidenalg-0.11.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:aa9b3bb5e75c5120b57cb8a3ba176610853359a9f5e787ecd6ba7e85c61f4fdc", size = 2254690, upload_time = "2025-10-31T17:14:42.153Z" }, - { url = "https://files.pythonhosted.org/packages/9b/50/ffad5f00e995ea064d1345867f6aa3534ebfb58e361309e088a12ef278d1/leidenalg-0.11.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:bed72262f181774de8425d6951c0e9c1d9c3d9bc64e030993e4215d6ab67933b", size = 1924396, upload_time = "2025-10-31T17:14:43.544Z" }, - { url = "https://files.pythonhosted.org/packages/9d/7c/7683c87e9d0bf70519a0d6cdbb27363b3520a4d5edd9c5f59181e393e2ff/leidenalg-0.11.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1672eb4b24c2c484c0f4d2225267a456751f4624ac99619fed4fb9e4a31049", size = 2379529, upload_time = "2025-10-31T17:14:44.67Z" }, - { url = "https://files.pythonhosted.org/packages/b8/0d/0560646fe75f6d161006cb6f164ef7d25096b9d59d68e836bce931ec8724/leidenalg-0.11.0-pp311-pypy311_pp73-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:3d9c74f9c3d97d1daec5cc9f43b56a62bb3f40d5fef9f2b5defd8d715bfb5cdd", size = 2683317, upload_time = "2025-10-31T17:14:46.03Z" }, - { url = "https://files.pythonhosted.org/packages/79/b9/d84d6225d79fed4fee19faef49e8b8fa58b70fcf55de5061bb1adf602eaa/leidenalg-0.11.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:023e97e4c96573e89fdea876da663e38694f6fa91e2c85e98455101409381d88", size = 2570109, upload_time = "2025-10-31T17:14:47.585Z" }, -] - -[[package]] -name = "markdown-it-py" -version = "4.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mdurl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload_time = "2025-08-11T12:57:52.854Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload_time = "2025-08-11T12:57:51.923Z" }, -] - -[[package]] -name = "markupsafe" -version = "3.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload_time = "2025-09-27T18:37:40.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload_time = "2025-09-27T18:36:05.558Z" }, - { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload_time = "2025-09-27T18:36:07.165Z" }, - { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload_time = "2025-09-27T18:36:08.005Z" }, - { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload_time = "2025-09-27T18:36:08.881Z" }, - { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload_time = "2025-09-27T18:36:10.131Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload_time = "2025-09-27T18:36:11.324Z" }, - { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload_time = "2025-09-27T18:36:12.573Z" }, - { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload_time = "2025-09-27T18:36:13.504Z" }, - { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload_time = "2025-09-27T18:36:14.779Z" }, - { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload_time = "2025-09-27T18:36:16.125Z" }, - { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload_time = "2025-09-27T18:36:17.311Z" }, - { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload_time = "2025-09-27T18:36:18.185Z" }, - { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload_time = "2025-09-27T18:36:19.444Z" }, - { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload_time = "2025-09-27T18:36:20.768Z" }, - { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload_time = "2025-09-27T18:36:22.249Z" }, - { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload_time = "2025-09-27T18:36:23.535Z" }, - { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload_time = "2025-09-27T18:36:24.823Z" }, - { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload_time = "2025-09-27T18:36:25.95Z" }, - { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload_time = "2025-09-27T18:36:27.109Z" }, - { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload_time = "2025-09-27T18:36:28.045Z" }, - { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload_time = "2025-09-27T18:36:29.025Z" }, - { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload_time = "2025-09-27T18:36:29.954Z" }, - { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload_time = "2025-09-27T18:36:30.854Z" }, - { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload_time = "2025-09-27T18:36:31.971Z" }, - { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload_time = "2025-09-27T18:36:32.813Z" }, - { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload_time = "2025-09-27T18:36:33.86Z" }, - { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload_time = "2025-09-27T18:36:35.099Z" }, - { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload_time = "2025-09-27T18:36:36.001Z" }, - { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload_time = "2025-09-27T18:36:36.906Z" }, - { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload_time = "2025-09-27T18:36:37.868Z" }, - { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload_time = "2025-09-27T18:36:38.761Z" }, - { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload_time = "2025-09-27T18:36:39.701Z" }, - { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload_time = "2025-09-27T18:36:40.689Z" }, - { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload_time = "2025-09-27T18:36:41.777Z" }, - { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload_time = "2025-09-27T18:36:43.257Z" }, - { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload_time = "2025-09-27T18:36:44.508Z" }, - { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload_time = "2025-09-27T18:36:45.385Z" }, - { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload_time = "2025-09-27T18:36:46.916Z" }, - { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload_time = "2025-09-27T18:36:47.884Z" }, - { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload_time = "2025-09-27T18:36:48.82Z" }, - { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload_time = "2025-09-27T18:36:49.797Z" }, - { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload_time = "2025-09-27T18:36:51.584Z" }, - { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload_time = "2025-09-27T18:36:52.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload_time = "2025-09-27T18:36:53.513Z" }, - { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload_time = "2025-09-27T18:36:54.819Z" }, - { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload_time = "2025-09-27T18:36:55.714Z" }, - { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload_time = "2025-09-27T18:36:56.908Z" }, - { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload_time = "2025-09-27T18:36:57.913Z" }, - { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload_time = "2025-09-27T18:36:58.833Z" }, - { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload_time = "2025-09-27T18:36:59.739Z" }, - { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload_time = "2025-09-27T18:37:00.719Z" }, - { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload_time = "2025-09-27T18:37:01.673Z" }, - { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload_time = "2025-09-27T18:37:02.639Z" }, - { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload_time = "2025-09-27T18:37:03.582Z" }, - { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload_time = "2025-09-27T18:37:04.929Z" }, - { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload_time = "2025-09-27T18:37:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload_time = "2025-09-27T18:37:07.213Z" }, - { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload_time = "2025-09-27T18:37:09.572Z" }, - { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload_time = "2025-09-27T18:37:10.58Z" }, - { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload_time = "2025-09-27T18:37:11.547Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload_time = "2025-09-27T18:37:12.48Z" }, - { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload_time = "2025-09-27T18:37:13.485Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload_time = "2025-09-27T18:37:14.408Z" }, - { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload_time = "2025-09-27T18:37:15.36Z" }, - { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload_time = "2025-09-27T18:37:16.496Z" }, - { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload_time = "2025-09-27T18:37:17.476Z" }, - { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload_time = "2025-09-27T18:37:18.453Z" }, - { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload_time = "2025-09-27T18:37:19.332Z" }, - { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload_time = "2025-09-27T18:37:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload_time = "2025-09-27T18:37:21.177Z" }, - { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload_time = "2025-09-27T18:37:22.167Z" }, - { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload_time = "2025-09-27T18:37:23.296Z" }, - { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload_time = "2025-09-27T18:37:24.237Z" }, - { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload_time = "2025-09-27T18:37:25.271Z" }, - { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload_time = "2025-09-27T18:37:26.285Z" }, - { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload_time = "2025-09-27T18:37:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload_time = "2025-09-27T18:37:28.327Z" }, -] - -[[package]] -name = "mcp" -version = "1.27.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, - { name = "jsonschema" }, - { name = "pydantic" }, - { name = "pydantic-settings" }, - { name = "pyjwt", extra = ["crypto"] }, - { name = "python-multipart" }, - { name = "pywin32", marker = "sys_platform == 'win32'" }, - { name = "sse-starlette" }, - { name = "starlette" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, - { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8b/eb/c0cfc62075dc6e1ec1c64d352ae09ac051d9334311ed226f1f425312848a/mcp-1.27.0.tar.gz", hash = "sha256:d3dc35a7eec0d458c1da4976a48f982097ddaab87e278c5511d5a4a56e852b83", size = 607509, upload_time = "2026-04-02T14:48:08.88Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/46/f6b4ad632c67ef35209a66127e4bddc95759649dd595f71f13fba11bdf9a/mcp-1.27.0-py3-none-any.whl", hash = "sha256:5ce1fa81614958e267b21fb2aa34e0aea8e2c6ede60d52aba45fd47246b4d741", size = 215967, upload_time = "2026-04-02T14:48:07.24Z" }, -] - -[[package]] -name = "mdurl" -version = "0.1.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload_time = "2022-08-14T12:40:10.846Z" } wheels = [