devmind-cli 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.
devmind/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ # DevMind: Codebase Memory for Developers
2
+ __version__ = "0.1.0"
devmind/cli.py ADDED
@@ -0,0 +1,285 @@
1
+ # pyrefly: ignore [missing-import]
2
+ import typer
3
+ import sys
4
+ import asyncio
5
+ import os
6
+ import logging
7
+ import warnings
8
+
9
+ # Suppress ResourceWarning and DeprecationWarning from aiohttp/asyncio during garbage collection
10
+ warnings.filterwarnings("ignore", category=ResourceWarning)
11
+ warnings.filterwarnings("ignore", category=DeprecationWarning)
12
+
13
+ # Suppress Windows proactor event loop SSL bugs during shutdown
14
+ if sys.platform == 'win32':
15
+ asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
16
+
17
+ from devmind.memory import initialize_cognee, remember_content, recall_query, improve_memory, forget_memory
18
+ from devmind.ingestion.file_reader import scan_codebase_files
19
+ from devmind.ingestion.git_parser import get_git_history
20
+ from devmind.ingestion.comment_extractor import get_codebase_comments
21
+
22
+ # Setup logging
23
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
24
+ logger = logging.getLogger("devmind.cli")
25
+
26
+ def run_async(coro):
27
+ """
28
+ Custom asyncio runner that sets an exception handler to swallow
29
+ noisy Win32 socket teardown/closed event loop warnings on shutdown.
30
+ """
31
+ loop = asyncio.new_event_loop()
32
+ asyncio.set_event_loop(loop)
33
+
34
+ def silence_exceptions(loop, context):
35
+ exc = context.get("exception")
36
+ msg = context.get("message", "")
37
+ # Swallows Win32 10038/not-a-socket/Event loop is closed warnings during exit
38
+ if (exc and ("Event loop is closed" in str(exc) or "10038" in str(exc) or "socket" in str(exc))) or "Event loop is closed" in msg or "SSL transport" in msg:
39
+ return
40
+ loop.default_exception_handler(context)
41
+
42
+ loop.set_exception_handler(silence_exceptions)
43
+ try:
44
+ return loop.run_until_complete(coro)
45
+ finally:
46
+ try:
47
+ loop.run_until_complete(loop.shutdown_asyncgens())
48
+ except Exception:
49
+ pass
50
+ loop.close()
51
+
52
+ app = typer.Typer(
53
+ name="devmind",
54
+ help="DevMind – Codebase Memory for Developers. Powered by Cognee.",
55
+ add_completion=False
56
+ )
57
+
58
+ async def remember_pipeline(directory: str):
59
+ """
60
+ Core async pipeline for scanning files, comments, and git logs,
61
+ and loading them into Cognee.
62
+ """
63
+ # 1. Scan the codebase files
64
+ files = scan_codebase_files(directory)
65
+ if not files:
66
+ typer.echo("No files found to ingest.")
67
+ return
68
+
69
+ typer.echo(f"Ingesting {len(files)} files into Cognee memory...")
70
+
71
+ # Ingest file contents
72
+ file_success = 0
73
+ for idx, file_data in enumerate(files, start=1):
74
+ rel_path = file_data["relative_path"]
75
+ content = file_data["content"]
76
+
77
+ tagged_content = f"File Path: {rel_path}\n---\n{content}"
78
+ dataset_name = rel_path.replace("/", "_").replace("\\", "_").replace(".", "_").replace(" ", "_")
79
+
80
+ logger.info(f"[{idx}/{len(files)}] Processing {rel_path}...")
81
+ success = await remember_content(tagged_content, dataset_name=dataset_name)
82
+ if success:
83
+ file_success += 1
84
+
85
+ typer.echo(f"Successfully remembered {file_success}/{len(files)} files.")
86
+
87
+ # 2. Extract and Ingest Git History
88
+ git_logs = get_git_history(directory, max_commits=20)
89
+ if git_logs:
90
+ typer.echo(f"Ingesting git history ({len(git_logs)} commits) into Cognee...")
91
+ git_success = 0
92
+ for idx, commit_log in enumerate(git_logs, start=1):
93
+ dataset_name = f"git_commit_{idx}"
94
+ success = await remember_content(commit_log, dataset_name=dataset_name)
95
+ if success:
96
+ git_success += 1
97
+ typer.echo(f"Successfully remembered {git_success}/{len(git_logs)} commits.")
98
+
99
+ # 3. Extract and Ingest Inline Comments & Docstrings
100
+ relative_paths = [f["relative_path"] for f in files]
101
+ comments = get_codebase_comments(directory, relative_paths)
102
+ if comments:
103
+ typer.echo(f"Ingesting inline comments ({len(comments)} files containing comments)...")
104
+ comment_success = 0
105
+ for idx, comment_block in enumerate(comments, start=1):
106
+ dataset_name = f"code_comments_{idx}"
107
+ success = await remember_content(comment_block, dataset_name=dataset_name)
108
+ if success:
109
+ comment_success += 1
110
+ typer.echo(f"Successfully remembered {comment_success}/{len(comments)} comment segments.")
111
+
112
+ @app.command()
113
+ def remember(
114
+ directory: str = typer.Option(
115
+ ".",
116
+ "--dir", "-d",
117
+ help="The directory of the codebase to ingest."
118
+ )
119
+ ):
120
+ """
121
+ Ingest the codebase files into persistent Cognee memory.
122
+ """
123
+ initialize_cognee()
124
+ run_async(remember_pipeline(directory))
125
+ typer.echo("[Success] Codebase memory ingestion completed.")
126
+
127
+ @app.command()
128
+ def ask(
129
+ query: str = typer.Argument(..., help="Your natural language question about the codebase.")
130
+ ):
131
+ """
132
+ Ask a question about the ingested codebase memory in plain English.
133
+ """
134
+ initialize_cognee()
135
+
136
+ typer.echo(f"Querying codebase memory for: '{query}'...")
137
+ answer = run_async(recall_query(query))
138
+
139
+ typer.echo("\n--- Response ---")
140
+ typer.echo(answer)
141
+ typer.echo("----------------")
142
+
143
+ @app.command()
144
+ def chat():
145
+ """
146
+ Start an interactive DevMind terminal chat session to explore your codebase.
147
+ """
148
+ initialize_cognee()
149
+
150
+ from rich.console import Console
151
+ from rich.markdown import Markdown
152
+ from rich.panel import Panel
153
+ from rich.prompt import Prompt
154
+
155
+ console = Console()
156
+ console.print(Panel.fit("[bold blue]DevMind Codebase Chat[/bold blue]\n[dim]Type your queries below. Type 'exit' or 'quit' to close.[/dim]", border_style="blue"))
157
+
158
+ while True:
159
+ try:
160
+ query = Prompt.ask("\n[bold green]You[/bold green]")
161
+ if not query.strip():
162
+ continue
163
+ if query.lower().strip() in ['exit', 'quit', 'clear']:
164
+ console.print("[dim]Goodbye![/dim]")
165
+ break
166
+
167
+ with console.status("[bold cyan]DevMind is thinking...[/bold cyan]", spinner="dots"):
168
+ answer = run_async(recall_query(query))
169
+
170
+ console.print("\n[bold magenta]DevMind:[/bold magenta]")
171
+ console.print(Markdown(answer))
172
+ except (KeyboardInterrupt, EOFError):
173
+ console.print("\n[dim]Goodbye![/dim]")
174
+ break
175
+ except Exception as e:
176
+ console.print(f"[bold red]Error:[/bold red] {str(e)}")
177
+
178
+ @app.command()
179
+ def log(
180
+ decision: str = typer.Argument(..., help="The Architectural Decision Record (ADR) text to log.")
181
+ ):
182
+ """
183
+ Log an Architectural Decision Record (ADR) into persistent memory.
184
+ """
185
+ initialize_cognee()
186
+ typer.echo(f"Logging decision: '{decision}'...")
187
+
188
+ tagged_decision = f"Architectural Decision Record:\n{decision}"
189
+ import time
190
+ dataset_name = f"adr_decision_{int(time.time())}"
191
+
192
+ success = run_async(remember_content(tagged_decision, dataset_name=dataset_name))
193
+ if success:
194
+ typer.echo("[Success] Architectural decision successfully logged.")
195
+ else:
196
+ typer.echo("[Error] Failed to log architectural decision.")
197
+
198
+ @app.command()
199
+ def refresh(
200
+ directory: str = typer.Option(
201
+ ".",
202
+ "--dir", "-d",
203
+ help="The directory of the codebase to refresh."
204
+ )
205
+ ):
206
+ """
207
+ Refresh codebase memory by scanning for changed files and refining relationships.
208
+ """
209
+ initialize_cognee()
210
+ typer.echo("Scanning for codebase changes to refresh memory...")
211
+ run_async(remember_pipeline(directory))
212
+
213
+ typer.echo("Refining the codebase memory graph structure...")
214
+ # Improve memory on all dataset partitions
215
+ success = run_async(improve_memory(dataset_name="codebase_memory"))
216
+ if success:
217
+ typer.echo("[Success] Memory refresh and relationship refinement completed.")
218
+ else:
219
+ typer.echo("[Warning] File changes re-ingested, but relationship refinement had warnings.")
220
+
221
+ @app.command()
222
+ def forget(
223
+ file_path: str = typer.Option(
224
+ None,
225
+ "--file", "-f",
226
+ help="The relative path of the file memory to forget."
227
+ ),
228
+ all_memories: bool = typer.Option(
229
+ False,
230
+ "--all", "-a",
231
+ help="Wipe all local memory databases completely."
232
+ )
233
+ ):
234
+ """
235
+ Surgically forget a specific file's memory, or completely wipe the local databases.
236
+ """
237
+ initialize_cognee()
238
+
239
+ if all_memories:
240
+ typer.echo("Wiping all local memory databases...")
241
+ import shutil
242
+ from devmind.memory import system_path, data_path
243
+ try:
244
+ if os.path.exists(system_path):
245
+ shutil.rmtree(system_path)
246
+ if os.path.exists(data_path):
247
+ shutil.rmtree(data_path)
248
+ typer.echo("[Success] Local memory databases completely wiped.")
249
+ except Exception as e:
250
+ typer.echo(f"[Error] Failed to wipe memory folders: {e}")
251
+ return
252
+
253
+ if file_path:
254
+ dataset_name = file_path.replace("/", "_").replace("\\", "_").replace(".", "_").replace(" ", "_")
255
+ typer.echo(f"Removing memory dataset '{dataset_name}'...")
256
+ success = run_async(forget_memory(dataset_name))
257
+ if success:
258
+ typer.echo(f"[Success] Memory of '{file_path}' successfully forgotten.")
259
+ else:
260
+ typer.echo(f"[Error] Failed to forget memory of '{file_path}'.")
261
+ else:
262
+ typer.echo("[Warning] Please specify either --file <path> to forget a file, or --all to wipe all databases.")
263
+
264
+ @app.command()
265
+ def dashboard(
266
+ port: int = typer.Option(8000, "--port", "-p", help="Port to run the dashboard server on.")
267
+ ):
268
+ """
269
+ Launch the DevMind Web UI dashboard.
270
+ """
271
+ import uvicorn
272
+ typer.echo(f"Starting DevMind Web UI Dashboard on http://localhost:{port} ...")
273
+ uvicorn.run("devmind.web.app:app", host="127.0.0.1", port=port, reload=False)
274
+
275
+ @app.command()
276
+ def mcp():
277
+ """
278
+ Start the DevMind MCP server for integration with Claude Code.
279
+ """
280
+ typer.echo("Starting DevMind MCP Server...")
281
+ from devmind.integrations.claude_code import mcp as mcp_instance
282
+ mcp_instance.run()
283
+
284
+ if __name__ == "__main__":
285
+ app()
@@ -0,0 +1,91 @@
1
+ import os
2
+ import re
3
+ import logging
4
+
5
+ logger = logging.getLogger("devmind.comment_extractor")
6
+
7
+ # Regex to find common developer tags in comments
8
+ TAG_PATTERN = re.compile(r"\b(TODO|FIXME|NOTE|BUG|HACK|WARNING|ADR|DEPRECATED)\b", re.IGNORECASE)
9
+
10
+ def extract_comments_from_file(file_path: str) -> list[str]:
11
+ """
12
+ Parses a single file, extracting inline comments and docstrings
13
+ that contain key developer tags (TODO, FIXME, NOTE, HACK, etc.).
14
+ """
15
+ extracted = []
16
+ _, ext = os.path.splitext(file_path.lower())
17
+
18
+ try:
19
+ with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
20
+ content = f.read()
21
+
22
+ lines = content.splitlines()
23
+
24
+ # 1. Python parsing (extract hash comments and triple-quoted docstrings)
25
+ if ext in (".py", ".sh", ".yaml", ".yml", ".ini"):
26
+ # Inline comment scanner
27
+ for idx, line in enumerate(lines, start=1):
28
+ comment_match = re.search(r"#\s*(.*)", line)
29
+ if comment_match:
30
+ comment_text = comment_match.group(1).strip()
31
+ if TAG_PATTERN.search(comment_text):
32
+ extracted.append(f"Line {idx}: {comment_text}")
33
+
34
+ # Simple regex search for docstrings
35
+ docstrings = re.findall(r'"""(.*?)"""', content, re.DOTALL)
36
+ docstrings.extend(re.findall(r"'''(.*?)'''", content, re.DOTALL))
37
+ for doc in docstrings:
38
+ doc_clean = doc.strip()
39
+ if doc_clean:
40
+ extracted.append(f"Docstring: {doc_clean}")
41
+
42
+ # 2. C-Style languages parsing (JS, TS, C, C++, Go, Java, Rust)
43
+ elif ext in (".js", ".ts", ".jsx", ".tsx", ".c", ".cpp", ".h", ".go", ".java", ".rs", ".css"):
44
+ # Inline comment scanner (// ...)
45
+ for idx, line in enumerate(lines, start=1):
46
+ comment_match = re.search(r"//\s*(.*)", line)
47
+ if comment_match:
48
+ comment_text = comment_match.group(1).strip()
49
+ if TAG_PATTERN.search(comment_text):
50
+ extracted.append(f"Line {idx}: {comment_text}")
51
+
52
+ # Block comment scanner (/* ... */)
53
+ block_comments = re.findall(r"/\*(.*?)\*/", content, re.DOTALL)
54
+ for block in block_comments:
55
+ for idx, block_line in enumerate(block.splitlines(), start=1):
56
+ block_line_clean = block_line.strip().lstrip("*").strip()
57
+ if TAG_PATTERN.search(block_line_clean):
58
+ extracted.append(f"Block Comment: {block_line_clean}")
59
+
60
+ # 3. HTML/XML/Markdown C-style comments (<!-- ... -->)
61
+ elif ext in (".html", ".htm", ".xml", ".md"):
62
+ html_comments = re.findall(r"<!--(.*?)-->", content, re.DOTALL)
63
+ for block in html_comments:
64
+ for block_line in block.splitlines():
65
+ block_line_clean = block_line.strip()
66
+ if TAG_PATTERN.search(block_line_clean):
67
+ extracted.append(f"HTML Comment: {block_line_clean}")
68
+
69
+ except Exception as e:
70
+ logger.error(f"Error parsing comments from {file_path}: {e}")
71
+
72
+ return extracted
73
+
74
+ def get_codebase_comments(repo_path: str, source_files: list[str]) -> list[str]:
75
+ """
76
+ Loops through all project files and extracts formatted comments/docstrings.
77
+ Returns a list of structured comment records.
78
+ """
79
+ all_comments = []
80
+ logger.info("Scanning codebase for inline comments and docstrings...")
81
+
82
+ for file_path in source_files:
83
+ abs_path = os.path.join(repo_path, file_path)
84
+ file_comments = extract_comments_from_file(abs_path)
85
+
86
+ if file_comments:
87
+ comment_log = [f"File Path: {file_path}"]
88
+ comment_log.extend(file_comments)
89
+ all_comments.append("\n".join(comment_log))
90
+
91
+ return all_comments
@@ -0,0 +1,92 @@
1
+ import os
2
+ import pathlib
3
+ import logging
4
+
5
+ logger = logging.getLogger("devmind.ingestion.file_reader")
6
+
7
+ # Common directories to skip during scanning
8
+ IGNORED_DIRS = {
9
+ ".git",
10
+ ".github",
11
+ "__pycache__",
12
+ ".venv",
13
+ "venv",
14
+ "env",
15
+ "node_modules",
16
+ ".cognee_store",
17
+ ".pytest_cache",
18
+ "dist",
19
+ "build",
20
+ "eggs",
21
+ ".eggs",
22
+ }
23
+
24
+ # Supported file extensions for codebase reading
25
+ SUPPORTED_EXTENSIONS = {
26
+ ".py", ".md", ".txt", ".js", ".ts", ".html", ".css",
27
+ ".json", ".yaml", ".yml", ".ini", ".toml", ".sh", ".bat"
28
+ }
29
+
30
+ # Individual configuration files to include even if they don't have standard extensions
31
+ EXPLICIT_FILES = {
32
+ "requirements.txt",
33
+ "setup.py",
34
+ "package.json",
35
+ "Dockerfile",
36
+ "Makefile",
37
+ "LICENSE",
38
+ "gitignore",
39
+ ".gitignore",
40
+ ".env.example",
41
+ }
42
+
43
+ def is_text_file(file_path: pathlib.Path) -> bool:
44
+ """
45
+ Check if a file should be read by extension or filename.
46
+ """
47
+ if file_path.suffix.lower() in SUPPORTED_EXTENSIONS:
48
+ return True
49
+ if file_path.name in EXPLICIT_FILES:
50
+ return True
51
+ return False
52
+
53
+ def scan_codebase_files(root_dir: str) -> list[dict]:
54
+ """
55
+ Recursively scans the root directory for code and documentation files.
56
+ Returns a list of dicts:
57
+ [
58
+ {
59
+ "relative_path": "path/to/file.py",
60
+ "absolute_path": "/absolute/path/to/file.py",
61
+ "content": "file content..."
62
+ }
63
+ ]
64
+ """
65
+ root_path = pathlib.Path(root_dir).resolve()
66
+ logger.info(f"Scanning codebase files under: {root_path}")
67
+
68
+ codebase_files = []
69
+
70
+ for dirpath, dirnames, filenames in os.walk(root_path):
71
+ # Modify dirnames in place to skip ignored directories
72
+ dirnames[:] = [d for d in dirnames if d not in IGNORED_DIRS]
73
+
74
+ for filename in filenames:
75
+ file_path = pathlib.Path(dirpath) / filename
76
+
77
+ if is_text_file(file_path):
78
+ relative_path = file_path.relative_to(root_path)
79
+ try:
80
+ with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
81
+ content = f.read()
82
+
83
+ codebase_files.append({
84
+ "relative_path": str(relative_path),
85
+ "absolute_path": str(file_path),
86
+ "content": content
87
+ })
88
+ except Exception as e:
89
+ logger.warning(f"Skipping file {relative_path} due to read error: {e}")
90
+
91
+ logger.info(f"Scan complete. Found {len(codebase_files)} indexable files.")
92
+ return codebase_files
@@ -0,0 +1,72 @@
1
+ import os
2
+ import logging
3
+ from git import Repo
4
+
5
+ logger = logging.getLogger("devmind.git_parser")
6
+
7
+ def get_git_history(repo_path: str, max_commits: int = 50) -> list[str]:
8
+ """
9
+ Scans the local git history of the project at repo_path.
10
+ Extracts commit messages, authors, dates, and diffs for each commit,
11
+ returning a list of formatted text logs ready for Cognee ingestion.
12
+ """
13
+ history_logs = []
14
+ try:
15
+ # Check if directory has a git repository initialized
16
+ git_dir = os.path.join(repo_path, ".git")
17
+ if not os.path.exists(git_dir):
18
+ logger.warning(f"No git repository found at '{repo_path}'. Skipping git log ingestion.")
19
+ return history_logs
20
+
21
+ repo = Repo(repo_path)
22
+ if repo.bare:
23
+ logger.warning(f"Git repository at '{repo_path}' is bare. Skipping git log ingestion.")
24
+ return history_logs
25
+
26
+ # Get commits on active branch
27
+ try:
28
+ commits = list(repo.iter_commits(max_count=max_commits))
29
+ except Exception:
30
+ logger.warning("No commits found in the repository (possibly empty). Skipping git history.")
31
+ return history_logs
32
+
33
+ logger.info(f"Extracting git history for the last {len(commits)} commits...")
34
+
35
+ for commit in commits:
36
+ commit_info = [
37
+ f"Commit Hash: {commit.hexsha}",
38
+ f"Author: {commit.author.name} <{commit.author.email}>",
39
+ f"Date: {commit.authored_datetime.isoformat()}",
40
+ f"Message: {commit.message.strip()}"
41
+ ]
42
+
43
+ # Extract list of files modified and the summary diffs
44
+ changed_files = []
45
+ if len(commit.parents) > 0:
46
+ # Diff against parent commit
47
+ parent = commit.parents[0]
48
+ diffs = parent.diff(commit, create_patch=True)
49
+ for d in diffs:
50
+ file_path = d.b_path if d.b_path else d.a_path
51
+ changed_files.append(file_path)
52
+
53
+ # Truncate patch to avoid overwhelming token limits
54
+ patch_text = d.diff.decode("utf-8", errors="ignore") if d.diff else ""
55
+ if len(patch_text) > 1000:
56
+ patch_text = patch_text[:1000] + "\n... [diff truncated for token optimization]"
57
+
58
+ commit_info.append(f"\nDiff for {file_path}:\n{patch_text}")
59
+ else:
60
+ # Root commit (first commit has no parent)
61
+ for file_path in commit.stats.files.keys():
62
+ changed_files.append(file_path)
63
+ commit_info.append(f"\nFiles added in root commit: {', '.join(changed_files)}")
64
+
65
+ commit_log = "\n".join(commit_info)
66
+ history_logs.append(commit_log)
67
+
68
+ return history_logs
69
+
70
+ except Exception as e:
71
+ logger.error(f"Error parsing git history: {e}", exc_info=True)
72
+ return history_logs
@@ -0,0 +1,44 @@
1
+ import os
2
+ import time
3
+ import asyncio
4
+ from fastmcp import FastMCP
5
+ from devmind.memory import recall_query, remember_content
6
+
7
+ # Initialize FastMCP Server
8
+ mcp = FastMCP(
9
+ "DevMind",
10
+ dependencies=["cognee", "fastmcp"]
11
+ )
12
+
13
+ @mcp.tool()
14
+ async def query_codebase_memory(query: str) -> str:
15
+ """
16
+ Queries the DevMind persistent codebase memory for details about the codebase structure,
17
+ design choices, git commit history, comments, and architectural decisions.
18
+ """
19
+ try:
20
+ answer = await recall_query(query)
21
+ return answer
22
+ except Exception as e:
23
+ return f"Error querying codebase memory: {e}"
24
+
25
+ @mcp.tool()
26
+ async def log_architectural_decision(decision: str) -> str:
27
+ """
28
+ Logs an Architectural Decision Record (ADR) into the codebase's persistent memory.
29
+ Use this when introducing major design changes, library switches, or design patterns.
30
+ """
31
+ try:
32
+ dataset_name = f"adr_decision_{int(time.time())}"
33
+ tagged_decision = f"Architectural Decision Record:\n{decision}"
34
+ success = await remember_content(tagged_decision, dataset_name=dataset_name)
35
+ if success:
36
+ return "Successfully logged architectural decision to memory."
37
+ else:
38
+ return "Failed to log architectural decision."
39
+ except Exception as e:
40
+ return f"Error logging decision: {e}"
41
+
42
+ if __name__ == "__main__":
43
+ # Start the MCP server (running over stdin/stdout transport)
44
+ mcp.run()
devmind/memory.py ADDED
@@ -0,0 +1,261 @@
1
+ import os
2
+ import logging
3
+ import random
4
+ import asyncio
5
+ from dotenv import load_dotenv
6
+
7
+ # Set up logging
8
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
9
+ logger = logging.getLogger("devmind.memory")
10
+
11
+ # Load dotenv and set project-scoped directories BEFORE importing cognee
12
+ load_dotenv()
13
+
14
+ project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
15
+ system_path = os.path.join(project_root, ".cognee_system")
16
+ data_path = os.path.join(project_root, ".cognee_data")
17
+
18
+ os.makedirs(system_path, exist_ok=True)
19
+ os.makedirs(os.path.join(system_path, "databases"), exist_ok=True)
20
+ os.makedirs(data_path, exist_ok=True)
21
+
22
+ # Set environment variables for Cognee root paths
23
+ os.environ["SYSTEM_ROOT_DIRECTORY"] = system_path
24
+ os.environ["DATA_ROOT_DIRECTORY"] = data_path
25
+ os.environ["CACHE_ROOT_DIRECTORY"] = os.path.join(project_root, ".cognee_cache")
26
+ os.environ["ENABLE_BACKEND_ACCESS_CONTROL"] = "false"
27
+ os.environ["LOG_LEVEL"] = "WARNING"
28
+
29
+ import cognee
30
+
31
+ # Global list of keys for rotation
32
+ _GROQ_API_KEYS = []
33
+
34
+ def load_api_keys():
35
+ """
36
+ Loads all available Groq API keys from the environment.
37
+ Supports a comma-separated list via GROQ_API_KEYS, falling back to GROQ_API_KEY.
38
+ """
39
+ global _GROQ_API_KEYS
40
+ load_dotenv()
41
+
42
+ # Read GROQ_API_KEYS comma-separated list
43
+ keys_str = os.getenv("GROQ_API_KEYS", "")
44
+ if keys_str:
45
+ _GROQ_API_KEYS = [k.strip() for k in keys_str.split(",") if k.strip()]
46
+
47
+ # Fallback to single GROQ_API_KEY if not already in the list
48
+ single_key = os.getenv("GROQ_API_KEY", "")
49
+ if single_key and single_key not in _GROQ_API_KEYS:
50
+ _GROQ_API_KEYS.append(single_key)
51
+
52
+ def get_random_api_key() -> tuple[str, str, str]:
53
+ """
54
+ Selects a random API key from the list (supports both Groq and OpenRouter keys)
55
+ and returns (key, endpoint, model) appropriate for that provider.
56
+ """
57
+ if not _GROQ_API_KEYS:
58
+ return "", "", ""
59
+ selected_key = random.choice(_GROQ_API_KEYS)
60
+
61
+ # Auto-detect provider based on key prefix
62
+ if selected_key.startswith("sk-or-v1-"):
63
+ endpoint = "https://openrouter.ai/api/v1"
64
+ model = os.getenv("LLM_MODEL_OPENROUTER", "openrouter/meta-llama/llama-3.3-70b-instruct")
65
+ provider_name = "OpenRouter"
66
+ else:
67
+ endpoint = "https://api.groq.com/openai/v1"
68
+ model = os.getenv("LLM_MODEL_GROQ", "groq/llama-3.3-70b-versatile")
69
+ provider_name = "Groq"
70
+
71
+ if len(selected_key) > 10:
72
+ masked = f"{selected_key[:6]}...{selected_key[-4:]}"
73
+ else:
74
+ masked = "***"
75
+ logger.info(f"Rotating LLM request key -> {masked} ({provider_name} key, model: {model})")
76
+ return selected_key, endpoint, model
77
+
78
+ def initialize_cognee():
79
+ """
80
+ Loads configuration from .env and verifies LLM & Embedding provider setup.
81
+ """
82
+ load_dotenv()
83
+ load_api_keys()
84
+
85
+ # Disable backend access control and authentication for local CLI use
86
+ os.environ["ENABLE_BACKEND_ACCESS_CONTROL"] = "false"
87
+
88
+ # Apply storage paths to Cognee configuration
89
+ cognee.config.system_root_directory(system_path)
90
+ cognee.config.data_root_directory(data_path)
91
+
92
+ llm_provider = os.getenv("LLM_PROVIDER", "groq").lower()
93
+ embedding_provider = os.getenv("EMBEDDING_PROVIDER", "fastembed").lower()
94
+
95
+ # Cognee does not natively support "groq" in its LLMProvider enum.
96
+ # We map "groq" to the "custom" provider utilizing Groq's OpenAI-compatible endpoint.
97
+ if llm_provider == "groq":
98
+ groq_key, endpoint, model = get_random_api_key()
99
+ os.environ["LLM_PROVIDER"] = "custom"
100
+ os.environ["LLM_ENDPOINT"] = endpoint
101
+ os.environ["LLM_API_KEY"] = groq_key
102
+
103
+ cognee.config.set_llm_provider("custom")
104
+ cognee.config.set_llm_endpoint(endpoint)
105
+ cognee.config.set_llm_api_key(groq_key)
106
+ cognee.config.set_llm_model(model)
107
+ if not groq_key:
108
+ print("[Error] No LLM API keys found. Please set GROQ_API_KEYS or GROQ_API_KEY in your .env file.")
109
+ import sys
110
+ sys.exit(1)
111
+ else:
112
+ openai_key = os.getenv("OPENAI_API_KEY", "")
113
+ cognee.config.set_llm_provider(llm_provider)
114
+ cognee.config.set_llm_model(os.getenv("LLM_MODEL", "openai/gpt-4o-mini"))
115
+ cognee.config.set_llm_api_key(openai_key)
116
+ if llm_provider == "openai" and not openai_key:
117
+ print("[Error] OPENAI_API_KEY is not set in your environment.")
118
+ import sys
119
+ sys.exit(1)
120
+
121
+ # Configure embedding provider
122
+ cognee.config.set_embedding_provider(embedding_provider)
123
+ cognee.config.set_embedding_model(os.getenv("EMBEDDING_MODEL", "BAAI/bge-small-en-v1.5"))
124
+ cognee.config.set_embedding_dimensions(int(os.getenv("EMBEDDING_DIMENSIONS", "384")))
125
+
126
+ logger.info(f"Initializing DevMind memory layer...")
127
+ logger.info(f"LLM Provider: {llm_provider} (Mapped to custom OpenAI-compatible endpoint if groq)")
128
+ logger.info(f"Embedding Provider: {embedding_provider} (Model: {os.getenv('EMBEDDING_MODEL')})")
129
+ logger.info(f"System Storage Path: {system_path}")
130
+ logger.info(f"Data Storage Path: {data_path}")
131
+
132
+ async def remember_content(content: str, dataset_name: str) -> bool:
133
+ """
134
+ Ingests text content into Cognee memory under a specified dataset name.
135
+ """
136
+ try:
137
+ # Rotate API key if we are on custom/groq rotation
138
+ llm_provider = os.getenv("LLM_PROVIDER", "groq").lower()
139
+ if llm_provider == "groq" or os.environ.get("LLM_PROVIDER") == "custom":
140
+ groq_key, endpoint, model = get_random_api_key()
141
+ if groq_key:
142
+ os.environ["LLM_API_KEY"] = groq_key
143
+ os.environ["LLM_ENDPOINT"] = endpoint
144
+ cognee.config.set_llm_endpoint(endpoint)
145
+ cognee.config.set_llm_api_key(groq_key)
146
+ cognee.config.set_llm_model(model)
147
+
148
+ logger.info(f"Remembering content in dataset '{dataset_name}'...")
149
+ await cognee.remember(content, dataset_name=dataset_name)
150
+ logger.info(f"Successfully remembered dataset '{dataset_name}'.")
151
+ return True
152
+ except Exception as e:
153
+ logger.error(f"Error during cognee.remember for '{dataset_name}': {e}", exc_info=True)
154
+ return False
155
+
156
+ async def get_all_dataset_names() -> list[str]:
157
+ """
158
+ Fetches all registered dataset names from Cognee's relational metadata.
159
+ """
160
+ try:
161
+ from cognee.infrastructure.databases.relational import get_relational_engine
162
+ from sqlalchemy import select
163
+ from cognee.modules.data.models import Dataset
164
+
165
+ engine = get_relational_engine()
166
+ async with engine.get_async_session() as session:
167
+ stmt = select(Dataset)
168
+ results = (await session.execute(stmt)).scalars().all()
169
+ return [d.name for d in results if d.name]
170
+ except Exception as e:
171
+ logger.warning(f"Could not fetch dataset names dynamically: {e}")
172
+ return []
173
+
174
+ async def recall_query(query: str) -> str:
175
+ """
176
+ Queries the Cognee memory graph using natural language.
177
+ """
178
+ try:
179
+ # Rotate API key if we are on custom/groq rotation
180
+ llm_provider = os.getenv("LLM_PROVIDER", "groq").lower()
181
+ if llm_provider == "groq" or os.environ.get("LLM_PROVIDER") == "custom":
182
+ groq_key, endpoint, model = get_random_api_key()
183
+ if groq_key:
184
+ os.environ["LLM_API_KEY"] = groq_key
185
+ os.environ["LLM_ENDPOINT"] = endpoint
186
+ cognee.config.set_llm_endpoint(endpoint)
187
+ cognee.config.set_llm_api_key(groq_key)
188
+ cognee.config.set_llm_model(model)
189
+
190
+ logger.info(f"Recalling memory for query: '{query}'...")
191
+ datasets = await get_all_dataset_names()
192
+ from cognee.modules.search.types import SearchType
193
+ query_type = SearchType.RAG_COMPLETION
194
+
195
+ if datasets:
196
+ logger.info(f"Searching across datasets individually in parallel: {datasets}")
197
+ # Query each dataset in parallel to bypass Cognee's single-dataset check
198
+ tasks = [cognee.recall(query_text=query, query_type=query_type, datasets=[d]) for d in datasets]
199
+ results_lists = await asyncio.gather(*tasks, return_exceptions=True)
200
+
201
+ results = []
202
+ for r_list in results_lists:
203
+ if isinstance(r_list, list):
204
+ results.extend(r_list)
205
+ elif isinstance(r_list, Exception):
206
+ logger.warning(f"Error recalling from a dataset partition: {r_list}")
207
+ else:
208
+ results = await cognee.recall(query_text=query, query_type=query_type)
209
+
210
+ if not results:
211
+ return "No relevant memories found."
212
+
213
+ # Cognee returns a list of result objects or dictionaries.
214
+ # Format the output cleanly for console display.
215
+ formatted_results = []
216
+ for index, result in enumerate(results, start=1):
217
+ if hasattr(result, "text"):
218
+ formatted_results.append(result.text)
219
+ elif isinstance(result, dict) and "text" in result:
220
+ formatted_results.append(result["text"])
221
+ else:
222
+ formatted_results.append(str(result))
223
+
224
+ return "\n\n".join(formatted_results)
225
+ except Exception as e:
226
+ logger.error(f"Error during cognee.recall for query '{query}': {e}", exc_info=True)
227
+ return f"Error recalling memory: {e}"
228
+
229
+ async def improve_memory(dataset_name: str) -> bool:
230
+ """
231
+ Re-enriches and prunes relationships for a given dataset in Cognee.
232
+ """
233
+ try:
234
+ logger.info(f"Improving Cognee memory for dataset '{dataset_name}'...")
235
+ await cognee.improve(dataset=dataset_name)
236
+ logger.info(f"Successfully improved memory for dataset '{dataset_name}'.")
237
+ return True
238
+ except Exception as e:
239
+ logger.error(f"Error during cognee.improve for '{dataset_name}': {e}", exc_info=True)
240
+ return False
241
+
242
+ async def forget_memory(dataset_name: str) -> bool:
243
+ """
244
+ Surgically deletes memory associated with a given dataset name.
245
+ """
246
+ try:
247
+ logger.info(f"Forgetting dataset '{dataset_name}' from Cognee memory...")
248
+ # Since cognee.forget might take dataset or dataset_name depending on local version,
249
+ # we try dataset first, then fallback to everything or other keywords if required.
250
+ try:
251
+ await cognee.forget(dataset=dataset_name)
252
+ except TypeError:
253
+ await cognee.forget(dataset_name=dataset_name)
254
+ logger.info(f"Successfully forgot dataset '{dataset_name}'.")
255
+ return True
256
+ except Exception as e:
257
+ if isinstance(e, AttributeError) and "'NoneType' object has no attribute 'id'" in str(e):
258
+ logger.info(f"Dataset '{dataset_name}' was not found in memory (it may have already been deleted or never ingested).")
259
+ return True
260
+ logger.error(f"Error during cognee.forget for '{dataset_name}': {e}", exc_info=True)
261
+ return False
devmind/web/app.py ADDED
@@ -0,0 +1,86 @@
1
+ import os
2
+ import sys
3
+ import asyncio
4
+ import logging
5
+
6
+ import warnings
7
+
8
+ # Suppress ResourceWarning and DeprecationWarning from aiohttp/asyncio during garbage collection
9
+ warnings.filterwarnings("ignore", category=ResourceWarning)
10
+ warnings.filterwarnings("ignore", category=DeprecationWarning)
11
+
12
+ # Suppress Windows proactor event loop SSL bugs during shutdown
13
+ if sys.platform == 'win32':
14
+ asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
15
+ from fastapi import FastAPI, Request, BackgroundTasks
16
+ from fastapi.responses import HTMLResponse, JSONResponse
17
+ from fastapi.templating import Jinja2Templates
18
+ from devmind.memory import recall_query, remember_content, initialize_cognee
19
+ from devmind.cli import remember_pipeline
20
+
21
+ logger = logging.getLogger("devmind.web")
22
+
23
+ app = FastAPI(title="DevMind Dashboard")
24
+
25
+ # Initialize Cognee configurations
26
+ initialize_cognee()
27
+
28
+ # Create templates folder and configuration
29
+ current_dir = os.path.dirname(os.path.abspath(__file__))
30
+ templates_path = os.path.join(current_dir, "templates")
31
+ os.makedirs(templates_path, exist_ok=True)
32
+ templates = Jinja2Templates(directory=templates_path)
33
+
34
+ @app.get("/", response_class=HTMLResponse)
35
+ async def read_index(request: Request):
36
+ """
37
+ Renders the DevMind UI dashboard.
38
+ """
39
+ return templates.TemplateResponse("index.html", {"request": request})
40
+
41
+ @app.post("/api/ask")
42
+ async def api_ask(payload: dict):
43
+ """
44
+ Handles natural language queries about the codebase.
45
+ """
46
+ query = payload.get("query", "")
47
+ if not query:
48
+ return JSONResponse({"error": "Query cannot be empty"}, status_code=400)
49
+
50
+ try:
51
+ answer = await recall_query(query)
52
+ return {"answer": answer}
53
+ except Exception as e:
54
+ logger.error(f"Error querying memory: {e}")
55
+ return JSONResponse({"error": str(e)}, status_code=500)
56
+
57
+ @app.post("/api/log")
58
+ async def api_log(payload: dict):
59
+ """
60
+ Saves an Architectural Decision Record (ADR) into memory.
61
+ """
62
+ decision = payload.get("decision", "")
63
+ if not decision:
64
+ return JSONResponse({"error": "Decision cannot be empty"}, status_code=400)
65
+
66
+ try:
67
+ import time
68
+ dataset_name = f"adr_decision_{int(time.time())}"
69
+ success = await remember_content(f"Architectural Decision Record:\n{decision}", dataset_name=dataset_name)
70
+ return {"success": success}
71
+ except Exception as e:
72
+ logger.error(f"Error logging decision: {e}")
73
+ return JSONResponse({"error": str(e)}, status_code=500)
74
+
75
+ @app.post("/api/remember")
76
+ async def api_remember(background_tasks: BackgroundTasks):
77
+ """
78
+ Triggers codebase re-ingestion asynchronously in the background.
79
+ """
80
+ try:
81
+ project_dir = os.getcwd()
82
+ background_tasks.add_task(remember_pipeline, project_dir)
83
+ return {"status": "ingested", "message": "Codebase memory re-ingestion started in the background."}
84
+ except Exception as e:
85
+ logger.error(f"Error triggering remember: {e}")
86
+ return JSONResponse({"error": str(e)}, status_code=500)
@@ -0,0 +1,166 @@
1
+ Metadata-Version: 2.4
2
+ Name: devmind-cli
3
+ Version: 0.1.0
4
+ Summary: DevMind - Semantic Codebase Memory and Agentic Search for Developers
5
+ Author: Anishp-cell
6
+ Project-URL: Homepage, https://github.com/Anishp-cell/devmind-CLI
7
+ Project-URL: Issues, https://github.com/Anishp-cell/devmind-CLI/issues
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+ Requires-Dist: cognee[fastembed]>=0.1.0
16
+ Requires-Dist: typer[all]>=0.9.0
17
+ Requires-Dist: fastapi>=0.100.0
18
+ Requires-Dist: uvicorn>=0.22.0
19
+ Requires-Dist: gitpython>=3.1.30
20
+ Requires-Dist: fastmcp>=0.1.0
21
+ Requires-Dist: python-dotenv>=1.0.0
22
+ Requires-Dist: pydantic>=2.0.0
23
+ Requires-Dist: jinja2>=3.1.2
24
+ Requires-Dist: groq>=0.9.0
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=7.0; extra == "dev"
27
+ Requires-Dist: black>=23.0; extra == "dev"
28
+ Requires-Dist: isort>=5.12; extra == "dev"
29
+ Requires-Dist: mypy>=1.0; extra == "dev"
30
+
31
+ # DevMind – Codebase Memory for Developers
32
+
33
+ > "Your codebase finally has a memory."
34
+
35
+ DevMind is a developer CLI tool and local web interface that gives your codebase a persistent, queryable memory powered by **Cognee**. It scans source files, git commit history, comments, and architectural decisions, building a hybrid graph-vector knowledge store. This persistent memory allows developers and AI coding assistants (via MCP) to query the codebase in plain English and carry context across infinite sessions.
36
+
37
+ ---
38
+
39
+ ## Features
40
+
41
+ 1. **One-Command Ingestion** (`devmind remember`): Scans the codebase, git logs, and code comments to feed `cognee.remember()`.
42
+ 2. **Plain-English Q&A** (`devmind ask "..."`): Uses `cognee.recall()` to retrieve grounded, context-aware answers from the memory graph.
43
+ 3. **Decision Logging** (`devmind log "..."`): Records Architecture Decision Records (ADRs) to capture design reasoning.
44
+ 4. **Memory Refresh** (`devmind refresh`): Automatically detects modified files, updates the graph, and runs `cognee.improve()`.
45
+ 5. **Surgical Forget** (`devmind forget --file ...`): Prunes specific file memory from the knowledge graph using `cognee.forget()`.
46
+ 6. **Claude Code MCP Server** (`devmind mcp`): Seamlessly integrates with Claude Code or Cursor via standard Model Context Protocol (MCP).
47
+ 7. **Local Dashboard UI** (`devmind dashboard`): Provides a clean visual panel showing memory status, search queries, and recent decisions.
48
+ 8. **Smart API Key Rotation**: Automatically detects, formats, and rotates between multiple Groq and OpenRouter API keys to balance rate limits on free-tier LLM access.
49
+
50
+ ---
51
+
52
+ ## Installation & Setup
53
+
54
+ ### 1. Clone the repository
55
+ ```bash
56
+ git clone https://github.com/Anishp-cell/devmind-CLI.git
57
+ cd devmind-CLI
58
+ ```
59
+
60
+ ### 2. Configure Environment Variables
61
+ Copy `.env.example` to `.env` and fill in your keys:
62
+ ```bash
63
+ cp .env.example .env
64
+ ```
65
+ To run for **free**, configure your `.env` with a list of rotated API keys:
66
+ ```env
67
+ LLM_PROVIDER="groq"
68
+
69
+ # Add a comma-separated list of Groq keys (gsk_...) and/or OpenRouter keys (sk-or-v1-...)
70
+ # The CLI automatically load-balances and routes requests to the correct endpoints!
71
+ GROQ_API_KEYS="gsk_key1,sk-or-v1-key2,gsk_key3"
72
+
73
+ EMBEDDING_PROVIDER="fastembed"
74
+ EMBEDDING_MODEL="BAAI/bge-small-en-v1.5"
75
+ EMBEDDING_DIMENSIONS="384"
76
+ ```
77
+
78
+ ### 3. Install DevMind
79
+ Install the package in editable mode:
80
+ ```bash
81
+ pip install -e .
82
+ ```
83
+
84
+ ---
85
+
86
+ ## CLI Command Reference
87
+
88
+ * **Ingest Codebase**:
89
+ ```bash
90
+ devmind remember
91
+ ```
92
+ * **Ask a Question**:
93
+ ```bash
94
+ devmind ask "Why did we switch to redis for the queue?"
95
+ ```
96
+ * **Log an Architectural Decision (ADR)**:
97
+ ```bash
98
+ devmind log "Chose FastAPI for the web UI because it supports async routes natively."
99
+ ```
100
+ * **Refresh Changed Memory**:
101
+ ```bash
102
+ devmind refresh
103
+ ```
104
+ * **Forget a Specific File**:
105
+ ```bash
106
+ devmind forget --file devmind/web/app.py
107
+ ```
108
+ * **Wipe Local Database Cache**:
109
+ ```bash
110
+ devmind forget --all
111
+ ```
112
+ * **Launch Web Dashboard**:
113
+ ```bash
114
+ devmind dashboard --port 8000
115
+ ```
116
+ * **Start MCP Server**:
117
+ ```bash
118
+ devmind mcp
119
+ ```
120
+
121
+ ---
122
+
123
+ ## Running the Mock Demo Project
124
+
125
+ To test DevMind on a smaller project without polluting your main repo:
126
+ 1. Navigate to the demo directory:
127
+ ```bash
128
+ cd examples/demo_project
129
+ ```
130
+ 2. Build the memory of the demo:
131
+ ```bash
132
+ devmind remember --dir .
133
+ ```
134
+ 3. Query its memory:
135
+ ```bash
136
+ devmind ask "What open TODO tasks are left in main.py?"
137
+ devmind ask "Why do we use SQLite according to our architecture decisions?"
138
+ ```
139
+
140
+ ---
141
+
142
+ ## Claude Code MCP Integration
143
+
144
+ To connect Claude Code to DevMind's memory, add the server to your Claude MCP config:
145
+
146
+ ```bash
147
+ claude mcp add devmind "devmind mcp"
148
+ ```
149
+
150
+ Alternatively, configure your project-level `.mcp.json` file in your project root:
151
+ ```json
152
+ {
153
+ "mcpServers": {
154
+ "devmind": {
155
+ "command": "devmind",
156
+ "args": ["mcp"]
157
+ }
158
+ }
159
+ }
160
+ ```
161
+
162
+ ---
163
+
164
+ ## AI Assistant Declaration
165
+
166
+ Per the rules of **The Hangover Part AI Hackathon**, this project declares the use of **Claude** (via the Antigravity IDE agent) as an AI pair programmer.
@@ -0,0 +1,13 @@
1
+ devmind/__init__.py,sha256=y9-PZTQC2r3AMAK95S8mBrriUKQ8oXl-NpckGK3grDc,64
2
+ devmind/cli.py,sha256=IOmWf42d42rfo0zKVJs02P41mkxt4VYzJP_gN3ppY14,10240
3
+ devmind/memory.py,sha256=wQZroWDg6LK-66mmvR640cBSVBmbL4fYQxQEROe8KwM,11120
4
+ devmind/ingestion/comment_extractor.py,sha256=Q-tMymQ3xPhq8D6tOGyo_jXqxhVkLqOD6blwsLX41Go,3928
5
+ devmind/ingestion/file_reader.py,sha256=9Wetq20moPt5rxGedkxE7zsJajar04pacYOy3glCh3Q,2683
6
+ devmind/ingestion/git_parser.py,sha256=xA0zZeQGTUiCc7S4U1NbF3V7yQOZDgWPqyuhnPSxh3Y,2993
7
+ devmind/integrations/claude_code.py,sha256=w2RKVjz57lyaiu2vhRf1SkcyFlFfJx0WhWTY1V82RNk,1470
8
+ devmind/web/app.py,sha256=btsHTfU4YgZTf1oLc7LH7mAlaEbcxHyPJOQR1U_ugdU,3049
9
+ devmind_cli-0.1.0.dist-info/METADATA,sha256=4NAfTgnBWWRYivqZ31Al4PSJ9Ojx6VPFv_7dmKB34qo,5479
10
+ devmind_cli-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
11
+ devmind_cli-0.1.0.dist-info/entry_points.txt,sha256=hVEkT2A4Eon0gRaBlw4KO_gpi6UHq5-qJ99aUO3hRfI,44
12
+ devmind_cli-0.1.0.dist-info/top_level.txt,sha256=_DEg02cn_PSb0audseGNYo2Krcitio-Vuo_7duiIXGA,8
13
+ devmind_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ devmind = devmind.cli:app
@@ -0,0 +1 @@
1
+ devmind