librarian-code 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. librarian/__init__.py +3 -0
  2. librarian/__main__.py +3 -0
  3. librarian/actions/__init__.py +0 -0
  4. librarian/actions/file_ops.py +47 -0
  5. librarian/actions/safety.py +29 -0
  6. librarian/actions/shell_ops.py +49 -0
  7. librarian/adapter/__init__.py +0 -0
  8. librarian/adapter/base.py +11 -0
  9. librarian/adapter/groq_adapter.py +40 -0
  10. librarian/adapter/openrouter_adapter.py +58 -0
  11. librarian/cli.py +26 -0
  12. librarian/commands/__init__.py +0 -0
  13. librarian/commands/ask.py +46 -0
  14. librarian/commands/do.py +232 -0
  15. librarian/commands/init.py +96 -0
  16. librarian/commands/status.py +71 -0
  17. librarian/commands/undo.py +85 -0
  18. librarian/commands/why.py +47 -0
  19. librarian/exceptions.py +22 -0
  20. librarian/memory/__init__.py +0 -0
  21. librarian/memory/capsule.py +94 -0
  22. librarian/memory/chunker.py +183 -0
  23. librarian/memory/decision_log.py +36 -0
  24. librarian/memory/indexer.py +96 -0
  25. librarian/memory/retriever.py +62 -0
  26. librarian/orchestrator/__init__.py +0 -0
  27. librarian/orchestrator/core.py +47 -0
  28. librarian/orchestrator/router.py +17 -0
  29. librarian/skills/__init__.py +0 -0
  30. librarian/skills/bundled/__init__.py +0 -0
  31. librarian/skills/bundled/api-design/conventions.md +93 -0
  32. librarian/skills/bundled/python/conventions.md +59 -0
  33. librarian/skills/bundled/react/conventions.md +83 -0
  34. librarian/skills/bundled/web-dev/conventions.md +54 -0
  35. librarian/skills/loader.py +109 -0
  36. librarian/utils/__init__.py +0 -0
  37. librarian/utils/config.py +15 -0
  38. librarian/utils/logger.py +32 -0
  39. librarian/utils/token_tracker.py +16 -0
  40. librarian/utils/ui.py +97 -0
  41. librarian_code-0.1.0.dist-info/METADATA +180 -0
  42. librarian_code-0.1.0.dist-info/RECORD +45 -0
  43. librarian_code-0.1.0.dist-info/WHEEL +4 -0
  44. librarian_code-0.1.0.dist-info/entry_points.txt +2 -0
  45. librarian_code-0.1.0.dist-info/licenses/LICENSE.md +21 -0
librarian/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Librarian — a CLI coding agent with persistent project memory."""
2
+
3
+ __version__ = "0.1.0"
librarian/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from librarian.cli import app
2
+
3
+ app()
File without changes
@@ -0,0 +1,47 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+ IGNORED_PATHS = [".git", "node_modules", "__pycache__", ".librarian", "venv", ".env"]
5
+
6
+
7
+ def read_file(path: str) -> str:
8
+ try:
9
+ return Path(path).read_text(encoding="utf-8")
10
+ except UnicodeDecodeError:
11
+ return Path(path).read_text(encoding="latin-1")
12
+
13
+
14
+ def write_file(path: str, content: str) -> bool:
15
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
16
+ Path(path).write_text(content, encoding="utf-8")
17
+ return True
18
+
19
+
20
+ def edit_file(path: str, old: str, new: str) -> bool:
21
+ content = read_file(path)
22
+ count = content.count(old)
23
+ if count == 0:
24
+ raise ValueError(f"String not found in {path}")
25
+ if count > 1:
26
+ raise ValueError(f"Ambiguous edit: string appears {count} times in {path}")
27
+ content = content.replace(old, new, 1)
28
+ write_file(path, content)
29
+ return True
30
+
31
+
32
+ def list_files(directory: str, extensions: list[str] = None) -> list[str]:
33
+ ignored = get_ignored_paths()
34
+ results = []
35
+ for root, dirs, files in os.walk(directory):
36
+ dirs[:] = [d for d in dirs if d not in ignored]
37
+ for f in files:
38
+ if extensions:
39
+ ext = os.path.splitext(f)[1].lower()
40
+ if ext not in extensions:
41
+ continue
42
+ results.append(os.path.join(root, f))
43
+ return sorted(results)
44
+
45
+
46
+ def get_ignored_paths() -> list[str]:
47
+ return IGNORED_PATHS
@@ -0,0 +1,29 @@
1
+ from enum import Enum
2
+
3
+
4
+ class RiskLevel(Enum):
5
+ SAFE = "safe"
6
+ CONFIRM = "confirm"
7
+
8
+
9
+ CONFIRM_ACTIONS = [
10
+ "git push",
11
+ "git reset --hard",
12
+ "rm ",
13
+ "delete",
14
+ "drop table",
15
+ "truncate",
16
+ ]
17
+
18
+
19
+ def classify_action(action: str) -> RiskLevel:
20
+ action_lower = action.lower()
21
+ for pattern in CONFIRM_ACTIONS:
22
+ if pattern in action_lower:
23
+ return RiskLevel.CONFIRM
24
+ return RiskLevel.SAFE
25
+
26
+
27
+ def request_confirm(action: str) -> bool:
28
+ from rich.prompt import Confirm
29
+ return Confirm.ask(f"[bold #F59E0B]confirm:[/bold #F59E0B] {action}")
@@ -0,0 +1,49 @@
1
+ import subprocess
2
+ import shlex
3
+ from librarian.actions.safety import classify_action, RiskLevel, request_confirm
4
+
5
+
6
+ def run_command(cmd: str, cwd: str = None) -> tuple[int, str, str]:
7
+ if isinstance(cmd, str):
8
+ args = shlex.split(cmd)
9
+ else:
10
+ args = cmd
11
+ result = subprocess.run(
12
+ args, shell=False, cwd=cwd,
13
+ capture_output=True, text=True,
14
+ )
15
+ return result.returncode, result.stdout, result.stderr
16
+
17
+
18
+ def git_stage(files: list[str]) -> bool:
19
+ args = ["git", "add"] + files
20
+ code, _, err = run_command(args)
21
+ if code != 0:
22
+ raise RuntimeError(f"git add failed: {err}")
23
+ return True
24
+
25
+
26
+ def git_commit(message: str) -> bool:
27
+ args = ["git", "commit", "-m", message]
28
+ code, _, err = run_command(args)
29
+ if code != 0:
30
+ raise RuntimeError(f"git commit failed: {err}")
31
+ return True
32
+
33
+
34
+ def git_push() -> bool:
35
+ risk = classify_action("git push")
36
+ if risk == RiskLevel.CONFIRM:
37
+ if not request_confirm("push to remote?"):
38
+ return False
39
+ code, _, err = run_command("git push")
40
+ if code != 0:
41
+ raise RuntimeError(f"git push failed: {err}")
42
+ return True
43
+
44
+
45
+ def git_status() -> str:
46
+ code, out, err = run_command("git status --short")
47
+ if code != 0:
48
+ return err
49
+ return out.strip()
File without changes
@@ -0,0 +1,11 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+
4
+ class LLMAdapter(ABC):
5
+ @abstractmethod
6
+ def complete(self, system: str, prompt: str) -> str:
7
+ pass
8
+
9
+ @abstractmethod
10
+ def is_available(self) -> bool:
11
+ pass
@@ -0,0 +1,40 @@
1
+ from groq import Groq, RateLimitError as GroqRateLimitError, APIConnectionError
2
+ from librarian.adapter.base import LLMAdapter
3
+ from librarian.exceptions import RateLimitError, ProviderUnavailableError
4
+ from librarian.utils.config import GROQ_API_KEY
5
+
6
+
7
+ class GroqAdapter(LLMAdapter):
8
+ def __init__(self):
9
+ self.client = Groq(api_key=GROQ_API_KEY) if GROQ_API_KEY else None
10
+ self.model = "llama-3.3-70b-versatile"
11
+ self.tokens_used = 0
12
+
13
+ def complete(self, system: str, prompt: str) -> str:
14
+ if not self.client:
15
+ raise ProviderUnavailableError("GROQ_API_KEY not set")
16
+ try:
17
+ response = self.client.chat.completions.create(
18
+ model=self.model,
19
+ messages=[
20
+ {"role": "system", "content": system},
21
+ {"role": "user", "content": prompt},
22
+ ],
23
+ temperature=0.2,
24
+ max_tokens=4096,
25
+ )
26
+ self.tokens_used += response.usage.total_tokens
27
+ return response.choices[0].message.content
28
+ except GroqRateLimitError:
29
+ raise RateLimitError("Groq rate limit exceeded")
30
+ except APIConnectionError:
31
+ raise ProviderUnavailableError("Cannot connect to Groq")
32
+
33
+ def is_available(self) -> bool:
34
+ if not self.client:
35
+ return False
36
+ try:
37
+ self.client.models.list()
38
+ return True
39
+ except Exception:
40
+ return False
@@ -0,0 +1,58 @@
1
+ import httpx
2
+ from librarian.adapter.base import LLMAdapter
3
+ from librarian.exceptions import RateLimitError, ProviderUnavailableError
4
+ from librarian.utils.config import OPENROUTER_API_KEY
5
+
6
+ ENDPOINT = "https://openrouter.ai/api/v1/chat/completions"
7
+ MODEL = "qwen/qwen3-coder:free"
8
+ HEADERS = {
9
+ "HTTP-Referer": "https://github.com/Humble-Librarian/librarian-code",
10
+ "X-Title": "librarian",
11
+ }
12
+
13
+
14
+ class OpenRouterAdapter(LLMAdapter):
15
+ def __init__(self):
16
+ self.api_key = OPENROUTER_API_KEY
17
+ self.tokens_used = 0
18
+
19
+ def complete(self, system: str, prompt: str) -> str:
20
+ if not self.api_key:
21
+ raise ProviderUnavailableError("OPENROUTER_API_KEY not set")
22
+ headers = {**HEADERS, "Authorization": f"Bearer {self.api_key}"}
23
+ payload = {
24
+ "model": MODEL,
25
+ "messages": [
26
+ {"role": "system", "content": system},
27
+ {"role": "user", "content": prompt},
28
+ ],
29
+ "temperature": 0.2,
30
+ "max_tokens": 4096,
31
+ }
32
+ try:
33
+ with httpx.Client(timeout=60) as client:
34
+ resp = client.post(ENDPOINT, headers=headers, json=payload)
35
+ if resp.status_code == 429:
36
+ raise RateLimitError("OpenRouter rate limit exceeded")
37
+ resp.raise_for_status()
38
+ data = resp.json()
39
+ self.tokens_used += data.get("usage", {}).get("total_tokens", 0)
40
+ choices = data.get("choices", [])
41
+ if not choices or "message" not in choices[0]:
42
+ raise ProviderUnavailableError("Invalid API response format")
43
+ return choices[0]["message"]["content"]
44
+ except httpx.ConnectError:
45
+ raise ProviderUnavailableError("Cannot connect to OpenRouter")
46
+ except httpx.TimeoutException:
47
+ raise ProviderUnavailableError("OpenRouter request timed out")
48
+
49
+ def is_available(self) -> bool:
50
+ if not self.api_key:
51
+ return False
52
+ try:
53
+ headers = {**HEADERS, "Authorization": f"Bearer {self.api_key}"}
54
+ with httpx.Client(timeout=10) as client:
55
+ resp = client.get("https://openrouter.ai/api/v1/models", headers=headers)
56
+ return resp.status_code == 200
57
+ except Exception:
58
+ return False
librarian/cli.py ADDED
@@ -0,0 +1,26 @@
1
+ import typer
2
+ from librarian.commands import init, ask, do, why, undo, status
3
+ from librarian.utils.ui import print_banner, print_muted, print_warning
4
+
5
+ app = typer.Typer(
6
+ name="librarian",
7
+ help="A CLI coding agent with persistent project memory.",
8
+ add_completion=False,
9
+ )
10
+
11
+
12
+ @app.callback(invoke_without_command=True)
13
+ def main(ctx: typer.Context):
14
+ if ctx.invoked_subcommand is None:
15
+ print_banner()
16
+
17
+
18
+ app.command(name="init")(init.run)
19
+ app.command(name="ask")(ask.run)
20
+ app.command(name="do")(do.run)
21
+ app.command(name="why")(why.run)
22
+ app.command(name="undo")(undo.run)
23
+ app.command(name="status")(status.run)
24
+
25
+ if __name__ == "__main__":
26
+ app()
File without changes
@@ -0,0 +1,46 @@
1
+ from pathlib import Path
2
+ from librarian.utils.ui import print_header, print_warning, print_panel, print_muted
3
+ from librarian.utils.token_tracker import tracker
4
+ from librarian.orchestrator.core import ask as ask_llm
5
+ from librarian.memory.retriever import retrieve
6
+
7
+
8
+ def _check_api_keys():
9
+ from librarian.utils.config import GROQ_API_KEY, OPENROUTER_API_KEY
10
+ if not GROQ_API_KEY and not OPENROUTER_API_KEY:
11
+ print_warning("no API keys found")
12
+ print_muted(" set at least one API key in .env file:")
13
+ print_muted("")
14
+ print_muted(" GROQ_API_KEY=gsk_... (free at console.groq.com)")
15
+ print_muted(" OPENROUTER_API_KEY=sk-or-... (free at openrouter.ai)")
16
+ print_muted("")
17
+ return False
18
+ return True
19
+
20
+
21
+ def run(task: str):
22
+ if not Path(".librarian").exists():
23
+ print_header("librarian ask")
24
+ print_warning("project not initialised — run 'librarian init' first")
25
+ return
26
+
27
+ if not _check_api_keys():
28
+ return
29
+
30
+ print_header("librarian ask")
31
+
32
+ try:
33
+ chunks = retrieve(task, n_results=5)
34
+ sources = []
35
+ for c in chunks:
36
+ meta = c["metadata"]
37
+ sources.append(f"{meta['file_path']}:{meta.get('start_line', '?')}-{meta.get('end_line', '?')}")
38
+
39
+ response, provider, tokens = ask_llm(task)
40
+ tracker.add(provider, tokens)
41
+ print_panel(response, title="answer")
42
+ if sources:
43
+ print_muted(f" sources {', '.join(sources[:3])}")
44
+ print_muted(f" tokens {tokens} provider {provider}")
45
+ except Exception as e:
46
+ print_warning(f"error: {e}")
@@ -0,0 +1,232 @@
1
+ import json
2
+ import re
3
+ import shutil
4
+ from pathlib import Path
5
+ from librarian.utils.ui import (
6
+ print_header, print_warning, print_success, print_muted,
7
+ print_panel, confirm_action, console, INDIGO, WARNING, SUCCESS,
8
+ )
9
+ from librarian.utils.token_tracker import tracker
10
+ from librarian.orchestrator.core import read_librarian_md, build_system_prompt
11
+ from librarian.orchestrator.router import get_response
12
+ from librarian.memory.retriever import retrieve
13
+ from librarian.memory import capsule, decision_log
14
+ from librarian.actions.file_ops import read_file, write_file, edit_file
15
+ from librarian.actions.shell_ops import run_command
16
+ from librarian.actions.safety import classify_action, RiskLevel
17
+ from librarian.skills.loader import build_skill_context
18
+
19
+ DO_SYSTEM_PROMPT = """You are Librarian, a CLI coding agent. Respond ONLY with a JSON plan.
20
+
21
+ ACTION TYPES:
22
+
23
+ 1. create_file — for new files:
24
+ {"type":"create_file","file":"path","description":"what","content":"COMPLETE file"}
25
+
26
+ 2. edit_file — modify existing files:
27
+ {"type":"edit_file","file":"path","description":"what","old_code":"EXACT text","new_code":"replacement"}
28
+
29
+ 3. delete_file — remove files/folders:
30
+ {"type":"delete_file","file":"path","description":"why"}
31
+
32
+ 4. shell_command — run terminal commands:
33
+ {"type":"shell_command","command":"cmd","description":"what"}
34
+
35
+ RESPONSE FORMAT:
36
+ {"reasoning":"approach","actions":[...]}
37
+
38
+ RULES:
39
+ - content in create_file MUST be the complete, working, FULL file — never a stub, placeholder, or comment
40
+ - NEVER generate content like "// add code here" or empty tags — always write real, functional code
41
+ - For web projects (HTML/CSS/JS): prefer a single index.html with inline <style> and <script>
42
+ - old_code in edit_file must match the file EXACTLY including whitespace — if unsure, use create_file instead
43
+ - Return ONLY valid JSON — no markdown fences, no explanation
44
+ - Keep file contents under 200 lines to avoid truncation
45
+ """
46
+
47
+
48
+ def _parse_plan(raw: str) -> dict:
49
+ raw = raw.strip()
50
+ if raw.startswith("```"):
51
+ raw = re.sub(r"^```(?:json)?\n?", "", raw)
52
+ raw = re.sub(r"\n?```$", "", raw)
53
+
54
+ raw = raw.replace("\\'", "'")
55
+
56
+ try:
57
+ return json.loads(raw)
58
+ except json.JSONDecodeError:
59
+ pass
60
+
61
+ last_bracket = raw.rfind("]")
62
+ if last_bracket == -1:
63
+ raise json.JSONDecodeError("No JSON array found", raw, 0)
64
+ truncated = raw[:last_bracket] + "]}"
65
+
66
+ try:
67
+ return json.loads(truncated)
68
+ except json.JSONDecodeError:
69
+ raise json.JSONDecodeError("Could not parse plan", raw, 0)
70
+
71
+
72
+ def _format_chunks(chunks: list[dict]) -> str:
73
+ parts = []
74
+ for c in chunks:
75
+ meta = c["metadata"]
76
+ parts.append(f"--- {meta['file_path']}:{meta.get('start_line', '?')}-{meta.get('end_line', '?')} ---\n{c['content']}")
77
+ return "\n\n".join(parts)
78
+
79
+
80
+ def _show_plan(plan: dict, task: str):
81
+ print_panel(
82
+ f" task {task}\n\n reasoning {plan.get('reasoning', '—')}",
83
+ title="execution plan",
84
+ )
85
+ from rich.table import Table
86
+ table = Table(show_header=True, header_style=f"bold {INDIGO}")
87
+ table.add_column("#", width=3)
88
+ table.add_column("type", width=14)
89
+ table.add_column("description", width=40)
90
+ for i, action in enumerate(plan.get("actions", []), 1):
91
+ table.add_row(
92
+ str(i),
93
+ action.get("type", "?"),
94
+ action.get("description", "—"),
95
+ )
96
+ console.print(table)
97
+
98
+
99
+ def _execute_action(action: dict) -> dict:
100
+ action_type = action.get("type")
101
+ if action_type == "edit_file":
102
+ path = Path(action["file"])
103
+ if not path.exists():
104
+ raise FileNotFoundError(f"File not found: {action['file']}")
105
+ content = read_file(action["file"])
106
+ if action["old_code"] not in content:
107
+ raise ValueError(f"old_code not found in {action['file']} — file may have changed")
108
+ edit_file(action["file"], action["old_code"], action["new_code"])
109
+ return {"type": "edit_file", "file": action["file"], "status": "done"}
110
+ elif action_type == "create_file":
111
+ path = Path(action["file"])
112
+ if path.exists() and path.stat().st_size > 0:
113
+ content = action.get("content", "")
114
+ if not content or len(content.strip()) < 20:
115
+ raise ValueError(f"Refusing to overwrite {action['file']} with empty/stub content")
116
+ write_file(action["file"], action["content"])
117
+ return {"type": "create_file", "file": action["file"], "status": "done"}
118
+ elif action_type == "delete_file":
119
+ target = Path(action["file"])
120
+ if target.is_dir():
121
+ shutil.rmtree(target)
122
+ elif target.is_file():
123
+ target.unlink()
124
+ else:
125
+ raise FileNotFoundError(f"Not found: {action['file']}")
126
+ return {"type": "delete_file", "file": action["file"], "status": "done"}
127
+ elif action_type == "shell_command":
128
+ cmd = action["command"]
129
+ if cmd.strip().startswith("rm "):
130
+ import re as _re
131
+ paths = _re.findall(r"(?:^|\s)(\S+)", cmd.replace("rm ", "", 1))
132
+ for p in paths:
133
+ p = p.lstrip("-").lstrip("r").lstrip("f").strip()
134
+ target = Path(p)
135
+ if target.is_dir():
136
+ shutil.rmtree(target)
137
+ elif target.is_file():
138
+ target.unlink()
139
+ return {"type": "shell_command", "command": cmd, "status": "done"}
140
+ code, out, err = run_command(cmd)
141
+ return {"type": "shell_command", "command": cmd, "status": "done" if code == 0 else f"exit {code}"}
142
+ return {"type": action_type, "status": "unknown"}
143
+
144
+
145
+ def _check_api_keys():
146
+ from librarian.utils.config import GROQ_API_KEY, OPENROUTER_API_KEY
147
+ if not GROQ_API_KEY and not OPENROUTER_API_KEY:
148
+ print_warning("no API keys found")
149
+ print_muted(" set at least one API key in .env file:")
150
+ print_muted("")
151
+ print_muted(" GROQ_API_KEY=gsk_... (free at console.groq.com)")
152
+ print_muted(" OPENROUTER_API_KEY=sk-or-... (free at openrouter.ai)")
153
+ print_muted("")
154
+ return False
155
+ return True
156
+
157
+
158
+ def run(task: str):
159
+ if not Path(".librarian").exists():
160
+ print_header("librarian do")
161
+ print_warning("project not initialised — run 'librarian init' first")
162
+ return
163
+
164
+ if not _check_api_keys():
165
+ return
166
+
167
+ print_header("librarian do")
168
+
169
+ chunks = retrieve(task, n_results=7)
170
+ conventions = read_librarian_md()
171
+ skill_ctx = build_skill_context()
172
+
173
+ parts = [f"Project conventions:\n{conventions}"]
174
+ if skill_ctx:
175
+ parts.append(f"Domain best practices:\n{skill_ctx}")
176
+ if chunks:
177
+ context = _format_chunks(chunks)
178
+ parts.append(f"Relevant code:\n{context}")
179
+ parts.append(f"Task: {task}")
180
+ prompt = "\n\n".join(parts)
181
+
182
+ try:
183
+ raw_response, provider, tokens = get_response(DO_SYSTEM_PROMPT, prompt)
184
+ tracker.add(provider, tokens)
185
+ plan = _parse_plan(raw_response)
186
+ except json.JSONDecodeError:
187
+ print_warning("LLM returned invalid JSON — try rephrasing your task")
188
+ return
189
+ except Exception as e:
190
+ print_warning(f"error: {e}")
191
+ return
192
+
193
+ _show_plan(plan, task)
194
+
195
+ if not confirm_action("proceed with execution?"):
196
+ print_muted(" cancelled")
197
+ return
198
+
199
+ results = []
200
+ files_changed = []
201
+ for action in plan.get("actions", []):
202
+ risk_text = action.get("description", "") + " " + action.get("command", "") + " " + action.get("file", "")
203
+ if action.get("type") in ("delete_file",):
204
+ risk_text += " delete"
205
+ risk = classify_action(risk_text)
206
+ if risk == RiskLevel.CONFIRM:
207
+ if not confirm_action(f"execute: {action.get('description', '?')}"):
208
+ print_muted(f" skipped: {action.get('description', '?')}")
209
+ continue
210
+ try:
211
+ result = _execute_action(action)
212
+ results.append(result)
213
+ if "file" in action:
214
+ files_changed.append(action["file"])
215
+ print_success(f"done: {action.get('description', '?')}")
216
+ except Exception as e:
217
+ print_warning(f"failed: {action.get('description', '?')} — {e}")
218
+
219
+ decision_log.append({
220
+ "command": "do",
221
+ "task": task,
222
+ "actions_taken": results,
223
+ "files_changed": files_changed,
224
+ "llm_provider": provider,
225
+ "tokens_used": tokens,
226
+ "reasoning": plan.get("reasoning", ""),
227
+ })
228
+
229
+ if results:
230
+ capsule.create(task, plan.get("reasoning", ""), files_changed)
231
+ print_success(f"{len(results)} actions completed")
232
+ print_muted(f" tokens: {tokens} provider: {provider}")
@@ -0,0 +1,96 @@
1
+ import os
2
+ from pathlib import Path
3
+ from librarian.utils.ui import print_header, print_success, print_warning, print_muted
4
+ from librarian.memory.indexer import index_project
5
+
6
+
7
+ def _detect_languages() -> list[str]:
8
+ from librarian.actions.file_ops import list_files
9
+ files = list_files(".", None)
10
+ exts = set()
11
+ for f in files:
12
+ ext = os.path.splitext(f)[1].lower()
13
+ if ext:
14
+ exts.add(ext)
15
+ lang_map = {
16
+ ".py": "Python", ".js": "JavaScript", ".ts": "TypeScript",
17
+ ".jsx": "React JSX", ".tsx": "React TSX", ".go": "Go",
18
+ ".rs": "Rust", ".java": "Java", ".rb": "Ruby",
19
+ ".md": "Markdown", ".txt": "Text",
20
+ }
21
+ return [lang_map.get(e, e) for e in sorted(exts) if e in lang_map]
22
+
23
+
24
+ def _detect_package_manager() -> str:
25
+ if Path("pyproject.toml").exists():
26
+ return "uv / pip"
27
+ if Path("setup.py").exists():
28
+ return "pip"
29
+ if Path("package.json").exists():
30
+ return "npm"
31
+ if Path("Cargo.toml").exists():
32
+ return "cargo"
33
+ return "unknown"
34
+
35
+
36
+ def _generate_librarian_md(languages: list[str], package_manager: str):
37
+ content = f"""# LIBRARIAN.md — project conventions
38
+
39
+ ## language
40
+ {', '.join(languages) if languages else 'unknown'}
41
+
42
+ ## package manager
43
+ {package_manager}
44
+
45
+ ## style
46
+ - follow existing code conventions in the project
47
+ - use type hints where applicable
48
+ - keep functions focused and small
49
+
50
+ ## structure
51
+ (add project structure notes here)
52
+
53
+ ## things to avoid
54
+ - importing specific adapters directly (always use base.LLMAdapter)
55
+ - hardcoding API keys
56
+ - deleting files without confirmation
57
+
58
+ ## notes
59
+ (add project-specific notes here — librarian reads this on every run)
60
+ """
61
+ Path("LIBRARIAN.md").write_text(content, encoding="utf-8")
62
+
63
+
64
+ def run():
65
+ print_header("initialising project")
66
+
67
+ cwd = os.getcwd()
68
+ basename = os.path.basename(cwd)
69
+ if not basename or len(basename) < 2:
70
+ print_warning("cannot initialise from a root or drive directory")
71
+ print_muted(" cd into your project folder first, then run: librarian init")
72
+ return
73
+
74
+ librarian_dir = Path(".librarian")
75
+ if librarian_dir.exists():
76
+ print_warning(".librarian/ already exists — re-indexing")
77
+
78
+ librarian_dir.mkdir(exist_ok=True)
79
+
80
+ if not Path("LIBRARIAN.md").exists():
81
+ languages = _detect_languages()
82
+ pkg = _detect_package_manager()
83
+ _generate_librarian_md(languages, pkg)
84
+ print_success("LIBRARIAN.md created")
85
+ else:
86
+ print_muted(" LIBRARIAN.md already exists — skipping")
87
+
88
+ try:
89
+ meta = index_project()
90
+ print_success(f"{meta['file_count']} files, {meta['chunk_count']} chunks")
91
+ except Exception as e:
92
+ print_warning(f"indexing error: {e}")
93
+ return
94
+
95
+ print_success(".librarian/ initialised")
96
+ print_muted("\n ready. run: librarian ask \"what does this project do?\"")