Rahul-Samedavar commited on
Commit
3e802a5
·
1 Parent(s): 37e3b47
.gitignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ example_project/
2
+ __pycache__/
3
+ */__pycache__
4
+ *.pyc
5
+ env/
6
+ .env
7
+ static/script.js.txt
Dockerfile ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Read the doc: https://huggingface.co/docs/hub/spaces-sdks-docker
2
+ # you will also find guides on how best to write your Dockerfile
3
+
4
+ FROM python:3.9
5
+
6
+ RUN useradd -m -u 1000 user
7
+ USER user
8
+ ENV PATH="/home/user/.local/bin:$PATH"
9
+
10
+ WORKDIR /app
11
+
12
+ COPY --chown=user ./requirements.txt requirements.txt
13
+ RUN pip install --no-cache-dir --upgrade -r requirements.txt
14
+
15
+ COPY --chown=user . /app
16
+ CMD ["python", "./run.py"]
codescribe/__init__.py ADDED
File without changes
codescribe/cli.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import click
2
+ from .config import load_config
3
+ from .llm_handler import LLMHandler
4
+ from .orchestrator import DocstringOrchestrator
5
+ from .readme_generator import ReadmeGenerator
6
+
7
+ @click.group(context_settings=dict(help_option_names=['-h', '--help']))
8
+ @click.pass_context
9
+ def cli(ctx):
10
+ """
11
+ CodeScribe AI: A tool for AI-assisted project documentation.
12
+
13
+ Choose a command below (e.g., 'docstrings', 'readmes') and provide its options.
14
+ """
15
+ # This parent command now only handles setup common to all subcommands.
16
+ ctx.ensure_object(dict)
17
+ try:
18
+ config = load_config()
19
+ if not config.api_keys:
20
+ raise click.UsageError("No API keys found in the .env file. Please create one.")
21
+
22
+ ctx.obj['LLM_HANDLER'] = LLMHandler(config.api_keys)
23
+ click.echo(f"Initialized with {len(config.api_keys)} API keys.")
24
+
25
+ except Exception as e:
26
+ raise click.ClickException(f"Initialization failed: {e}")
27
+
28
+ @cli.command()
29
+ @click.option('--path', required=True, help='The local path or Git URL of the project.')
30
+ @click.option('--desc', required=True, help='A short description of the project.')
31
+ @click.option('--exclude', multiple=True, help='Directory/regex pattern to exclude. Can be used multiple times.')
32
+ @click.pass_context
33
+ def docstrings(ctx, path, desc, exclude):
34
+ """Generates Python docstrings for all files."""
35
+ click.echo("\n--- Starting Docstring Generation ---")
36
+ llm_handler = ctx.obj['LLM_HANDLER']
37
+
38
+ orchestrator = DocstringOrchestrator(
39
+ path_or_url=path,
40
+ description=desc,
41
+ exclude=list(exclude),
42
+ llm_handler=llm_handler
43
+ )
44
+ try:
45
+ orchestrator.run()
46
+ click.secho("Successfully generated all docstrings.", fg="green")
47
+ except Exception as e:
48
+ click.secho(f"An error occurred during docstring generation: {e}", fg="red")
49
+
50
+
51
+ @cli.command()
52
+ @click.option('--path', required=True, help='The local path or Git URL of the project.')
53
+ @click.option('--desc', required=True, help='A short description of the project.')
54
+ @click.option('--exclude', multiple=True, help='Directory/regex pattern to exclude. Can be used multiple times.')
55
+ @click.pass_context
56
+ def readmes(ctx, path, desc, exclude):
57
+ """Generates README.md files for each directory."""
58
+ click.echo("\n--- Starting README Generation ---")
59
+ llm_handler = ctx.obj['LLM_HANDLER']
60
+
61
+ generator = ReadmeGenerator(
62
+ path_or_url=path,
63
+ description=desc,
64
+ exclude=list(exclude),
65
+ llm_handler=llm_handler
66
+ )
67
+ try:
68
+ generator.run()
69
+ click.secho("Successfully generated all README files.", fg="green")
70
+ except Exception as e:
71
+ click.secho(f"An error occurred during README generation: {e}", fg="red")
72
+
73
+ def main():
74
+ cli(obj={})
75
+
76
+ if __name__ == '__main__':
77
+ main()
codescribe/config.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from dataclasses import dataclass, field
3
+ from dotenv import load_dotenv
4
+ from typing import List
5
+
6
+ @dataclass
7
+ class APIKey:
8
+ provider: str
9
+ key: str
10
+ model: str
11
+
12
+ def __repr__(self):
13
+ return f"APIKey(provider='{self.provider}', model='{self.model}')"
14
+
15
+ @dataclass
16
+ class Config:
17
+ api_keys: List[APIKey] = field(default_factory=list)
18
+
19
+ def load_config() -> Config:
20
+ """Loads API keys from .env file into a Config object."""
21
+ load_dotenv()
22
+
23
+ config = Config()
24
+
25
+ # Load Groq keys (GROQ_API_KEY_1, GRO-API_KEY_2, etc.)
26
+ i = 1
27
+ while True:
28
+ key = os.getenv(f"GROQ_API_KEY_{i}")
29
+ if not key:
30
+ break
31
+ config.api_keys.append(APIKey(provider="groq", key=key, model="llama3-70b-8192"))
32
+ i += 1
33
+
34
+ # Load Gemini keys (GEMINI_API_KEY_1, etc.)
35
+ i = 1
36
+ while True:
37
+ key = os.getenv(f"GEMINI_API_KEY_{i}")
38
+ if not key:
39
+ break
40
+ config.api_keys.append(APIKey(provider="gemini", key=key, model="gemini-1.5-flash"))
41
+ i += 1
42
+
43
+ if not config.api_keys:
44
+ print("Warning: No API keys found in .env file. Please create a .env file with GROQ_API_KEY_1 or GEMINI_API_KEY_1.")
45
+
46
+ return config
codescribe/llm_handler.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ import json
3
+ from typing import Dict, List, Callable
4
+ import google.generativeai as genai
5
+ from groq import Groq, RateLimitError
6
+
7
+
8
+
9
+ from .config import APIKey
10
+
11
+ def no_op_callback(message: str):
12
+ print(message)
13
+
14
+ class LLMHandler:
15
+ def __init__(self, api_keys: List[APIKey], progress_callback: Callable[[str], None] = no_op_callback):
16
+ self.clients = []
17
+ self.progress_callback = progress_callback # NEW
18
+ for key in api_keys:
19
+ if key.provider == "groq":
20
+ self.clients.append({
21
+ "provider": "groq",
22
+ "client": Groq(api_key=key.key),
23
+ "model": key.model,
24
+ "id": f"groq_{key.key[-4:]}"
25
+ })
26
+ elif key.provider == "gemini":
27
+ genai.configure(api_key=key.key)
28
+ self.clients.append({
29
+ "provider": "gemini",
30
+ "client": genai.GenerativeModel(key.model),
31
+ "model": key.model,
32
+ "id": f"gemini_{key.key[-4:]}"
33
+ })
34
+
35
+ self.cooldowns: Dict[str, float] = {}
36
+ self.cooldown_period = 30 # 30 seconds
37
+
38
+ def generate_documentation(self, prompt: str) -> Dict:
39
+ """
40
+ Tries to generate documentation using available clients, handling rate limits and failovers.
41
+ """
42
+ if not self.clients:
43
+ raise ValueError("No LLM clients configured.")
44
+
45
+ for client_info in self.clients:
46
+ client_id = client_info["id"]
47
+
48
+ # Check if the client is on cooldown
49
+ if client_id in self.cooldowns:
50
+ if time.time() - self.cooldowns[client_id] < self.cooldown_period:
51
+ self.progress_callback(f"Skipping {client_id} (on cooldown).")
52
+ continue
53
+ else:
54
+ # Cooldown has expired
55
+ del self.cooldowns[client_id]
56
+
57
+ try:
58
+ self.progress_callback(f"Attempting to generate docs with {client_id} ({client_info['model']})...")
59
+ if client_info["provider"] == "groq":
60
+ response = client_info["client"].chat.completions.create(
61
+ messages=[{"role": "user", "content": prompt}],
62
+ model=client_info["model"],
63
+ temperature=0.1,
64
+ response_format={"type": "json_object"},
65
+ )
66
+ content = response.choices[0].message.content
67
+
68
+ elif client_info["provider"] == "gemini":
69
+ response = client_info["client"].generate_content(prompt)
70
+ # Gemini might wrap JSON in ```json ... ```
71
+ content = response.text.strip().replace("```json", "").replace("```", "").strip()
72
+
73
+ return json.loads(content)
74
+
75
+ except RateLimitError:
76
+ self.progress_callback(f"Rate limit hit for {client_id}. Placing it on a {self.cooldown_period}s cooldown.")
77
+ self.cooldowns[client_id] = time.time()
78
+ continue
79
+ except Exception as e:
80
+ self.progress_callback(f"An error occurred with {client_id}: {e}. Trying next client.")
81
+ continue
82
+
83
+ raise RuntimeError("Failed to generate documentation from all available LLM providers.")
84
+
85
+
86
+ def generate_text_response(self, prompt: str) -> str:
87
+ """
88
+ Generates a plain text response from LLMs, handling failovers.
89
+ """
90
+ if not self.clients:
91
+ raise ValueError("No LLM clients configured.")
92
+
93
+ for client_info in self.clients:
94
+ client_id = client_info["id"]
95
+ if client_id in self.cooldowns and time.time() - self.cooldowns[client_id] < self.cooldown_period:
96
+ self.progress_callback(f"Skipping {client_id} (on cooldown).")
97
+ continue
98
+ elif client_id in self.cooldowns:
99
+ del self.cooldowns[client_id]
100
+
101
+ try:
102
+ self.progress_callback(f"Attempting to generate text with {client_id} ({client_info['model']})...")
103
+ if client_info["provider"] == "groq":
104
+ response = client_info["client"].chat.completions.create(
105
+ messages=[{"role": "user", "content": prompt}],
106
+ model=client_info["model"],
107
+ temperature=0.2,
108
+ )
109
+ return response.choices[0].message.content
110
+
111
+ elif client_info["provider"] == "gemini":
112
+ response = client_info["client"].generate_content(prompt)
113
+ return response.text.strip()
114
+
115
+ except RateLimitError:
116
+ self.progress_callback(f"Rate limit hit for {client_id}. Placing it on a {self.cooldown_period}s cooldown.")
117
+ self.cooldowns[client_id] = time.time()
118
+ continue
119
+ except Exception as e:
120
+ self.progress_callback(f"An error occurred with {client_id}: {e}. Trying next client.")
121
+ continue
122
+
123
+ raise RuntimeError("Failed to generate text response from all available LLM providers.")
codescribe/orchestrator.py ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import shutil
2
+ from pathlib import Path
3
+ from typing import List, Callable, Dict
4
+ import networkx as nx
5
+ import json
6
+ from collections import defaultdict
7
+
8
+ from . import scanner, parser, updater
9
+ from .llm_handler import LLMHandler
10
+
11
+ COMBINED_DOCSTRING_PROMPT_TEMPLATE = """
12
+ SYSTEM: You are an expert programmer writing high-quality, comprehensive Python docstrings in reStructuredText (reST) format. Your output MUST be a single JSON object.
13
+
14
+ USER:
15
+ Project Description:
16
+ \"\"\"
17
+ {project_description}
18
+ \"\"\"
19
+
20
+ ---
21
+ CONTEXT FROM DEPENDENCIES:
22
+ This file depends on other modules. Here is their documentation for context:
23
+
24
+ {dependency_context}
25
+ ---
26
+
27
+ DOCUMENT THE FOLLOWING SOURCE FILE:
28
+
29
+ File Path: `{file_path}`
30
+
31
+ ```python
32
+ {file_content}
33
+ ```
34
+ INSTRUCTIONS:
35
+ Provide a single JSON object as your response.
36
+ 1. The JSON object MUST have a special key `\"__module__\"`. The value for this key should be a concise, single-paragraph docstring that summarizes the purpose of the entire file.
37
+ 2. The other keys in the JSON object should be the function or class names (e.g., "my_function", "MyClass", "MyClass.my_method").
38
+ 3. The values for these other keys should be their complete docstrings.
39
+ 4. Do NOT include the original code in your response. Only generate the JSON containing the docstrings.
40
+ """
41
+
42
+ # --- UNCHANGED ---
43
+ PACKAGE_INIT_PROMPT_TEMPLATE = """
44
+ SYSTEM: You are an expert programmer writing a high-level, one-paragraph summary for a Python package. This summary will be the main docstring for the package's `__init__.py` file.
45
+
46
+ USER:
47
+ Project Description:
48
+ \"\"\"
49
+ {project_description}
50
+ \"\"\"
51
+
52
+ You are writing the docstring for the `__init__.py` of the `{package_name}` package.
53
+
54
+ This package contains the following modules. Their summaries are provided below:
55
+ {module_summaries}
56
+
57
+ INSTRUCTIONS:
58
+ Write a concise, single-paragraph docstring that summarizes the overall purpose and responsibility of the `{package_name}` package, based on the modules it contains. This docstring will be placed in the `__init__.py` file.
59
+ """
60
+
61
+ def no_op_callback(event: str, data: dict):
62
+ print(f"{event}: {json.dumps(data, indent=2)}")
63
+
64
+ class DocstringOrchestrator:
65
+ # --- UNCHANGED ---
66
+ def __init__(self, path_or_url: str, description: str, exclude: List[str], llm_handler: LLMHandler, progress_callback: Callable[[str, dict], None] = no_op_callback, repo_full_name: str = None):
67
+ self.path_or_url = path_or_url
68
+ self.description = description
69
+ self.exclude = exclude
70
+ self.llm_handler = llm_handler
71
+ self.progress_callback = progress_callback
72
+ self.project_path = None
73
+ self.is_temp_dir = path_or_url.startswith("http")
74
+ self.repo_full_name = repo_full_name
75
+
76
+ def llm_log_wrapper(message: str):
77
+ self.progress_callback("log", {"message": message})
78
+
79
+ self.llm_handler.progress_callback = llm_log_wrapper
80
+
81
+ # --- MODIFIED: The run() method is refactored ---
82
+ def run(self):
83
+ def log_to_ui(message: str):
84
+ self.progress_callback("log", {"message": message})
85
+
86
+ try:
87
+ self.project_path = scanner.get_project_path(self.path_or_url, log_callback=log_to_ui)
88
+
89
+ self.progress_callback("phase", {"id": "scan", "name": "Scanning Project", "status": "in-progress"})
90
+ files = scanner.scan_project(self.project_path, self.exclude)
91
+ log_to_ui(f"Found {len(files)} Python files to document.")
92
+ self.progress_callback("phase", {"id": "scan", "status": "success"})
93
+
94
+ graph = parser.build_dependency_graph(files, self.project_path, log_callback=log_to_ui)
95
+
96
+ self.progress_callback("phase", {"id": "docstrings", "name": "Generating Docstrings", "status": "in-progress"})
97
+ doc_order = list(nx.topological_sort(graph)) if nx.is_directed_acyclic_graph(graph) else list(graph.nodes)
98
+
99
+ # --- COMBINED PASS 1 & 2: Generate all docstrings in a single call per file ---
100
+ documented_context = {}
101
+ module_docstrings = {}
102
+ for file_path in doc_order:
103
+ rel_path = file_path.relative_to(self.project_path).as_posix()
104
+ # Use a single subtask for the entire file documentation process
105
+ self.progress_callback("subtask", {"parentId": "docstrings", "listId": "docstring-file-list", "id": f"doc-{rel_path}", "name": f"Documenting {rel_path}", "status": "in-progress"})
106
+
107
+ deps = graph.predecessors(file_path)
108
+ dep_context_str = "\n".join([f"File: `{dep.relative_to(self.project_path)}`\n{json.dumps(documented_context.get(dep, {}), indent=2)}\n" for dep in deps]) or "No internal dependencies have been documented yet."
109
+
110
+ with open(file_path, "r", encoding="utf-8") as f:
111
+ file_content = f.read()
112
+
113
+ # Use the new combined prompt
114
+ prompt = COMBINED_DOCSTRING_PROMPT_TEMPLATE.format(project_description=self.description, dependency_context=dep_context_str, file_path=rel_path, file_content=file_content)
115
+
116
+ try:
117
+ # Single LLM call to get all docstrings for the file
118
+ combined_docs = self.llm_handler.generate_documentation(prompt)
119
+
120
+ # Separate the module docstring from the function/class docstrings
121
+ module_summary = combined_docs.pop("__module__", None)
122
+ function_class_docs = combined_docs # The remainder of the dict
123
+
124
+ # Update the file with function/class docstrings
125
+ updater.update_file_with_docstrings(file_path, function_class_docs, log_callback=log_to_ui)
126
+
127
+ # Update the file with the module-level docstring if it was generated
128
+ if module_summary:
129
+ updater.update_module_docstring(file_path, module_summary, log_callback=log_to_ui)
130
+ module_docstrings[file_path] = module_summary
131
+
132
+ self.progress_callback("subtask", {"parentId": "docstrings", "id": f"doc-{rel_path}", "status": "success"})
133
+ documented_context[file_path] = function_class_docs # Store for dependency context
134
+ except Exception as e:
135
+ log_to_ui(f"Error processing docstrings for {rel_path}: {e}")
136
+ self.progress_callback("subtask", {"parentId": "docstrings", "id": f"doc-{rel_path}", "status": "error"})
137
+
138
+ # --- PASS 3: Generate __init__.py Docstrings for Packages (Unchanged) ---
139
+ packages = defaultdict(list)
140
+ for file_path, docstring in module_docstrings.items():
141
+ if file_path.name != "__init__.py":
142
+ packages[file_path.parent].append(f"- `{file_path.name}`: {docstring}")
143
+
144
+ for package_path, summaries in packages.items():
145
+ rel_path = package_path.relative_to(self.project_path).as_posix()
146
+ init_file = package_path / "__init__.py"
147
+ self.progress_callback("subtask", {"parentId": "docstrings", "listId": "docstring-package-list", "id": f"pkg-{rel_path}", "name": f"Package summary for {rel_path}", "status": "in-progress"})
148
+
149
+ try:
150
+ is_root_package = (package_path == self.project_path)
151
+ package_name = self.repo_full_name if is_root_package and self.repo_full_name else package_path.name
152
+
153
+ prompt = PACKAGE_INIT_PROMPT_TEMPLATE.format(
154
+ project_description=self.description,
155
+ package_name=package_name,
156
+ module_summaries="\n".join(summaries)
157
+ )
158
+ package_summary = self.llm_handler.generate_text_response(prompt).strip().strip('"""').strip("'''").strip()
159
+
160
+ if not init_file.exists():
161
+ init_file.touch()
162
+
163
+ updater.update_module_docstring(init_file, package_summary, log_callback=log_to_ui)
164
+ self.progress_callback("subtask", {"parentId": "docstrings", "id": f"pkg-{rel_path}", "status": "success"})
165
+ except Exception as e:
166
+ log_to_ui(f"Error generating package docstring for {rel_path}: {e}")
167
+ self.progress_callback("subtask", {"parentId": "docstrings", "id": f"pkg-{rel_path}", "status": "error"})
168
+
169
+ self.progress_callback("phase", {"id": "docstrings", "status": "success"})
170
+
171
+ finally:
172
+ if self.is_temp_dir and self.project_path and self.project_path.exists():
173
+ shutil.rmtree(self.project_path, ignore_errors=True)
codescribe/parser.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import ast
3
+ from pathlib import Path
4
+ from typing import List, Set, Callable # Add Callable
5
+ import networkx as nx
6
+
7
+ def resolve_import_path(current_file: Path, module_name: str, level: int, project_root: Path) -> Path | None:
8
+ # ... (function content remains unchanged) ...
9
+ if level > 0: # Relative import
10
+ base_path = current_file.parent
11
+ for _ in range(level - 1):
12
+ base_path = base_path.parent
13
+
14
+ module_path = base_path / Path(*module_name.split('.'))
15
+ else: # Absolute import
16
+ module_path = project_root / Path(*module_name.split('.'))
17
+
18
+ # Try to find the file (.py) or package (__init__.py)
19
+ if module_path.with_suffix(".py").exists():
20
+ return module_path.with_suffix(".py")
21
+ if (module_path / "__init__.py").exists():
22
+ return module_path / "__init__.py"
23
+
24
+ return None
25
+
26
+ def build_dependency_graph(file_paths: List[Path], project_root: Path, log_callback: Callable[[str], None] = print) -> nx.DiGraph:
27
+ """
28
+ Builds a dependency graph from a list of Python files.
29
+ Uses a callback for logging warnings.
30
+ """
31
+ graph = nx.DiGraph()
32
+ path_map = {p.stem: p for p in file_paths} # Simplified mapping
33
+
34
+ for file_path in file_paths:
35
+ graph.add_node(file_path)
36
+ try:
37
+ with open(file_path, "r", encoding="utf-8") as f:
38
+ content = f.read()
39
+ tree = ast.parse(content)
40
+
41
+ for node in ast.walk(tree):
42
+ if isinstance(node, ast.ImportFrom):
43
+ if node.module:
44
+ dep_path = resolve_import_path(file_path, node.module, node.level, project_root)
45
+ if dep_path and dep_path in file_paths:
46
+ graph.add_edge(file_path, dep_path)
47
+
48
+ except Exception as e:
49
+ # Use the callback instead of print
50
+ log_callback(f"Warning: Could not parse {file_path.name} for dependencies. Skipping. Error: {e}")
51
+
52
+ return graph
codescribe/readme_generator.py ADDED
@@ -0,0 +1,270 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # codescribe/readme_generator.py
2
+
3
+ import os
4
+ import shutil
5
+ import ast
6
+ from pathlib import Path
7
+ from typing import List, Callable
8
+
9
+ from . import scanner
10
+ from .llm_handler import LLMHandler
11
+
12
+ # Prompt templates remain unchanged.
13
+ SUBDIR_PROMPT_TEMPLATE = """
14
+ SYSTEM: You are an expert technical writer creating a README.md file for a specific directory within a larger project. Your tone should be informative and concise.
15
+
16
+ USER:
17
+ You are generating a `README.md` for the directory: `{current_dir_relative}`
18
+
19
+ The overall project description is:
20
+ "{project_description}"
21
+
22
+ ---
23
+ This directory contains the following source code files. Use them to describe the specific purpose of this directory:
24
+ {file_summaries}
25
+ ---
26
+
27
+ This directory also contains the following subdirectories. Use their `README.md` content (provided below) to summarize their roles:
28
+ {subdirectory_readmes}
29
+ ---
30
+
31
+ TASK:
32
+ Write a `README.md` for the `{current_dir_relative}` directory.
33
+ - Start with a heading (e.g., `# Directory: {dir_name}`).
34
+ - Briefly explain the purpose of this directory based on the files it contains.
35
+ - If there are subdirectories, provide a section summarizing what each one does, using the context from their READMEs.
36
+ - Use clear Markdown formatting. Do not describe the entire project; focus ONLY on the contents and role of THIS directory.
37
+ """
38
+
39
+ ROOT_PROMPT_TEMPLATE = """
40
+ SYSTEM: You are an expert technical writer creating the main `README.md` for an entire software project. Your tone should be welcoming and comprehensive.
41
+
42
+ USER:
43
+ You are generating the main `README.md` for a project.
44
+
45
+ The user-provided project description is:
46
+ "{project_description}"
47
+
48
+ ---
49
+ The project's root directory contains the following source code files:
50
+ {file_summaries}
51
+ ---
52
+
53
+ The project has the following main subdirectories. Use their `README.md` content (provided below) to describe the overall structure of the project:
54
+ {subdirectory_readmes}
55
+ ---
56
+
57
+ TASK:
58
+ Write a comprehensive `README.md` for the entire project. Structure it with the following sections:
59
+ - A main title (`# Project: {project_name}`).
60
+ - **Overview**: A slightly more detailed version of the user's description, enhanced with context from the files and subdirectories.
61
+ - **Project Structure**: A description of the key directories and their roles, using the information from the subdirectory READMEs.
62
+ - **Key Features**: Infer and list the key features of the project based on all the provided context.
63
+ """
64
+
65
+ UPDATE_SUBDIR_PROMPT_TEMPLATE = """
66
+ SYSTEM: You are an expert technical writer updating a README.md file for a specific directory. Your tone should be informative and concise. A user has provided a note with instructions.
67
+
68
+ USER:
69
+ You are updating the `README.md` for the directory: `{current_dir_relative}`
70
+
71
+ The user-provided note with instructions for this update is:
72
+ "{user_note}"
73
+
74
+ The overall project description is:
75
+ "{project_description}"
76
+ ---
77
+ This directory contains the following source code files. Use them to describe the specific purpose of this directory:
78
+ {file_summaries}
79
+ ---
80
+ This directory also contains the following subdirectories. Use their `README.md` content (provided below) to summarize their roles:
81
+ {subdirectory_readmes}
82
+ ---
83
+ Here is the OLD `README.md` content. You must update it based on the new context and the user's note.
84
+ ---
85
+ {existing_readme}
86
+ ---
87
+ TASK:
88
+ Rewrite the `README.md` for the `{current_dir_relative}` directory, incorporating the user's note and any new information from the files and subdirectories.
89
+ - Start with a heading (e.g., `# Directory: {dir_name}`).
90
+ - Use the existing content as a base, but modify it as needed.
91
+ - Use clear Markdown formatting. Do not describe the entire project; focus ONLY on the contents and role of THIS directory.
92
+ """
93
+
94
+ UPDATE_ROOT_PROMPT_TEMPLATE = """
95
+ SYSTEM: You are an expert technical writer updating the main `README.md` for an entire software project. Your tone should be welcoming and comprehensive. A user has provided a note with instructions.
96
+
97
+ USER:
98
+ You are updating the main `README.md` for a project.
99
+
100
+ The user-provided note with instructions for this update is:
101
+ "{user_note}"
102
+
103
+ The user-provided project description is:
104
+ "{project_description}"
105
+ ---
106
+ The project's root directory contains the following source code files:
107
+ {file_summaries}
108
+ ---
109
+ The project has the following main subdirectories. Use their `README.md` content (provided below) to describe the overall structure of the project:
110
+ {subdirectory_readmes}
111
+ ---
112
+ Here is the OLD `README.md` content. You must update it based on the new context and the user's note.
113
+ ---
114
+ {existing_readme}
115
+ ---
116
+ TASK:
117
+ Rewrite a comprehensive `README.md` for the entire project. Structure it with the following sections, using the old README as a base but incorporating changes based on the user's note and new context.
118
+ - A main title (`# Project: {project_name}`).
119
+ - **Overview**: An updated version of the user's description, enhanced with context.
120
+ - **Project Structure**: A description of the key directories and their roles.
121
+ - **Key Features**: Infer and list key features based on all the provided context.
122
+ """
123
+
124
+ def no_op_callback(event: str, data: dict):
125
+ pass
126
+
127
+ class ReadmeGenerator:
128
+ def __init__(self, path_or_url: str, description: str, exclude: List[str], llm_handler: LLMHandler, user_note: str = "", repo_full_name="", progress_callback: Callable[[str, dict], None] = no_op_callback):
129
+ self.path_or_url = path_or_url
130
+ self.description = description
131
+ self.exclude = exclude + ["README.md"]
132
+ self.llm_handler = llm_handler
133
+ self.user_note = user_note
134
+ self.progress_callback = progress_callback
135
+ self.project_path = None
136
+ self.is_temp_dir = path_or_url.startswith("http")
137
+ self.repo_full_name = repo_full_name
138
+
139
+ def llm_log_wrapper(message: str):
140
+ self.progress_callback("log", {"message": message})
141
+
142
+ self.llm_handler.progress_callback = llm_log_wrapper
143
+
144
+ def run(self):
145
+ """Public method to start README generation."""
146
+ try:
147
+ self.project_path = scanner.get_project_path(self.path_or_url)
148
+ self.progress_callback("phase", {"id": "readmes", "name": "Generating READMEs", "status": "in-progress"})
149
+ self.run_with_structured_logging()
150
+ self.progress_callback("phase", {"id": "readmes", "status": "success"})
151
+ except Exception as e:
152
+ self.progress_callback("log", {"message": f"An unexpected error occurred in README generation: {e}"})
153
+ self.progress_callback("phase", {"id": "readmes", "status": "error"})
154
+ raise
155
+ finally:
156
+ if self.is_temp_dir and self.project_path and self.project_path.exists():
157
+ shutil.rmtree(self.project_path, ignore_errors=True)
158
+
159
+ def _summarize_py_file(self, file_path: Path) -> str:
160
+ """
161
+ Extracts the module-level docstring, or a list of function/class names
162
+ as a fallback, to summarize a Python file.
163
+ """
164
+ try:
165
+ with open(file_path, "r", encoding="utf-8") as f:
166
+ content = f.read()
167
+ tree = ast.parse(content)
168
+
169
+ # 1. Prioritize the module-level docstring
170
+ docstring = ast.get_docstring(tree)
171
+ if docstring:
172
+ return f"`{file_path.name}`: {docstring.strip().splitlines()[0]}"
173
+
174
+ # 2. Fallback: Find function and class names
175
+ definitions = []
176
+ for node in tree.body:
177
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
178
+ definitions.append(node.name)
179
+
180
+ if definitions:
181
+ summary = f"Contains definitions for: `{', '.join(definitions)}`."
182
+ return f"`{file_path.name}`: {summary}"
183
+
184
+ except Exception as e:
185
+ self.progress_callback("log", {"message": f"Could not parse {file_path.name} for summary: {e}"})
186
+
187
+ # 3. Final fallback
188
+ return f"`{file_path.name}`: A Python source file."
189
+
190
+ def run_with_structured_logging(self):
191
+ """
192
+ Generates README files for each directory from the bottom up,
193
+ emitting structured events for the UI.
194
+ """
195
+ if not self.project_path:
196
+ self.project_path = scanner.get_project_path(self.path_or_url)
197
+
198
+ for dir_path, subdir_names, file_names in os.walk(self.project_path, topdown=False):
199
+ current_dir = Path(dir_path)
200
+
201
+ if scanner.is_excluded(current_dir, self.exclude, self.project_path):
202
+ continue
203
+
204
+ rel_path = current_dir.relative_to(self.project_path).as_posix()
205
+ dir_id = rel_path if rel_path != "." else "root"
206
+ dir_name_display = rel_path if rel_path != "." else "Project Root"
207
+
208
+ self.progress_callback("subtask", {"parentId": "readmes", "listId": "readme-dir-list", "id": dir_id, "name": f"Directory: {dir_name_display}", "status": "in-progress"})
209
+
210
+ try:
211
+ file_summaries = self._gather_file_summaries(current_dir, file_names)
212
+ subdirectory_readmes = self._gather_subdirectory_readmes(current_dir, subdir_names)
213
+
214
+ existing_readme_content = None
215
+ existing_readme_path = current_dir / "README.md"
216
+ if existing_readme_path.exists():
217
+ with open(existing_readme_path, "r", encoding="utf-8") as f:
218
+ existing_readme_content = f.read()
219
+
220
+ prompt = self._build_prompt(current_dir, file_summaries, subdirectory_readmes, existing_readme_content)
221
+ generated_content = self.llm_handler.generate_text_response(prompt)
222
+
223
+ with open(current_dir / "README.md", "w", encoding="utf-8") as f:
224
+ f.write(generated_content)
225
+
226
+ self.progress_callback("subtask", {"parentId": "readmes", "id": dir_id, "status": "success"})
227
+
228
+ except Exception as e:
229
+ self.progress_callback("log", {"message": f"Failed to generate README for {dir_name_display}: {e}"})
230
+ self.progress_callback("subtask", {"parentId": "readmes", "id": dir_id, "status": "error"})
231
+
232
+ # ... The rest of the file (_gather_file_summaries, _gather_subdirectory_readmes, _build_prompt) remains unchanged ...
233
+ def _gather_file_summaries(self, current_dir: Path, file_names: List[str]) -> str:
234
+ file_summaries_list = []
235
+ for fname in file_names:
236
+ if fname.endswith(".py"):
237
+ file_path = current_dir / fname
238
+ if not scanner.is_excluded(file_path, self.exclude, self.project_path):
239
+ file_summaries_list.append(self._summarize_py_file(file_path))
240
+ return "\n".join(file_summaries_list) or "No Python source files in this directory."
241
+
242
+ def _gather_subdirectory_readmes(self, current_dir: Path, subdir_names: List[str]) -> str:
243
+ subdir_readmes_list = []
244
+ for sub_name in subdir_names:
245
+ readme_path = current_dir / sub_name / "README.md"
246
+ if readme_path.exists():
247
+ with open(readme_path, "r", encoding="utf-8") as f:
248
+ content = f.read()
249
+ subdir_readmes_list.append(f"--- Subdirectory: `{sub_name}` ---\n{content}\n")
250
+ return "\n".join(subdir_readmes_list) or "No subdirectories with READMEs."
251
+
252
+ def _build_prompt(self, current_dir: Path, file_summaries: str, subdirectory_readmes: str, existing_readme: str | None) -> str:
253
+ is_root = current_dir == self.project_path
254
+ common_args = {
255
+ "project_description": self.description,
256
+ "file_summaries": file_summaries,
257
+ "subdirectory_readmes": subdirectory_readmes,
258
+ "user_note": self.user_note or "No specific instructions provided.",
259
+ }
260
+
261
+ if is_root:
262
+ template = UPDATE_ROOT_PROMPT_TEMPLATE if existing_readme else ROOT_PROMPT_TEMPLATE
263
+ args = {**common_args, "project_name": self.repo_full_name if self.repo_full_name else self.project_path.name}
264
+ if existing_readme: args["existing_readme"] = existing_readme
265
+ else: # is subdirectory
266
+ template = UPDATE_SUBDIR_PROMPT_TEMPLATE if existing_readme else SUBDIR_PROMPT_TEMPLATE
267
+ args = {**common_args, "current_dir_relative": current_dir.relative_to(self.project_path).as_posix(), "dir_name": current_dir.name}
268
+ if existing_readme: args["existing_readme"] = existing_readme
269
+
270
+ return template.format(**args)
codescribe/scanner.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import os
3
+ import re
4
+ from pathlib import Path
5
+ from typing import List, Callable # Add Callable
6
+ import git
7
+ import tempfile
8
+ import shutil
9
+
10
+ # ... is_excluded function is unchanged ...
11
+ def is_excluded(path: Path, exclude_patterns: List[str], project_root: Path) -> bool:
12
+ relative_path_str = path.relative_to(project_root).as_posix()
13
+ if any(part.startswith('.') for part in path.relative_to(project_root).parts):
14
+ return True
15
+ for pattern in exclude_patterns:
16
+ try:
17
+ if re.search(pattern, relative_path_str):
18
+ return True
19
+ except re.error:
20
+ if pattern in relative_path_str:
21
+ return True
22
+ return False
23
+
24
+ # ... scan_project function is unchanged ...
25
+ def scan_project(project_path: Path, exclude_patterns: List[str]) -> List[Path]:
26
+ py_files = []
27
+ for root, dirs, files in os.walk(project_path, topdown=True):
28
+ root_path = Path(root)
29
+ original_dirs = dirs[:]
30
+ dirs[:] = [d for d in original_dirs if not is_excluded(root_path / d, exclude_patterns, project_path)]
31
+ for file in files:
32
+ if file.endswith('.py'):
33
+ file_path = root_path / file
34
+ if not is_excluded(file_path, exclude_patterns, project_path):
35
+ py_files.append(file_path)
36
+ return py_files
37
+
38
+ def get_project_path(path_or_url: str, log_callback: Callable[[str], None] = print) -> Path:
39
+ """
40
+ Clones a repo if a URL is given, otherwise returns the Path object.
41
+ Uses a callback for logging.
42
+ """
43
+ if path_or_url.startswith("http") or path_or_url.startswith("git@"):
44
+ temp_dir = tempfile.mkdtemp()
45
+ # Use the callback instead of print
46
+ log_callback(f"Cloning repository {path_or_url} into temporary directory...")
47
+ try:
48
+ git.Repo.clone_from(path_or_url, temp_dir)
49
+ return Path(temp_dir)
50
+ except Exception as e:
51
+ shutil.rmtree(temp_dir)
52
+ raise RuntimeError(f"Failed to clone repository: {e}")
53
+ else:
54
+ path = Path(path_or_url)
55
+ if not path.is_dir():
56
+ raise FileNotFoundError(f"The specified path does not exist or is not a directory: {path}")
57
+ return path
codescribe/updater.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # codescribe/updater.py
2
+
3
+ import ast
4
+ from pathlib import Path
5
+ from typing import Dict, Callable
6
+
7
+ # Helper no-op function for default callback
8
+ def _no_op_log(message: str):
9
+ pass
10
+
11
+ class DocstringInserter(ast.NodeTransformer):
12
+ def __init__(self, docstrings: Dict[str, str]):
13
+ self.docstrings = docstrings
14
+ self.current_class = None
15
+
16
+ def visit_ClassDef(self, node: ast.ClassDef) -> ast.ClassDef:
17
+ self.current_class = node.name
18
+ if node.name in self.docstrings:
19
+ self._insert_docstring(node, self.docstrings[node.name])
20
+ self.generic_visit(node)
21
+ self.current_class = None
22
+ return node
23
+
24
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> ast.FunctionDef:
25
+ key = f"{self.current_class}.{node.name}" if self.current_class else node.name
26
+ if key in self.docstrings:
27
+ self._insert_docstring(node, self.docstrings[key])
28
+ return node
29
+
30
+ def _insert_docstring(self, node, docstring_text):
31
+ docstring_node = ast.Expr(value=ast.Constant(value=docstring_text))
32
+ if ast.get_docstring(node):
33
+ node.body[0] = docstring_node
34
+ else:
35
+ node.body.insert(0, docstring_node)
36
+
37
+ def update_file_with_docstrings(file_path: Path, docstrings: Dict[str, str], log_callback: Callable[[str], None] = print):
38
+ """
39
+ Parses a Python file, inserts docstrings for functions/classes, and overwrites the file.
40
+ """
41
+ try:
42
+ with open(file_path, "r", encoding="utf-8") as f:
43
+ source_code = f.read()
44
+
45
+ tree = ast.parse(source_code)
46
+ transformer = DocstringInserter(docstrings)
47
+ new_tree = transformer.visit(tree)
48
+ ast.fix_missing_locations(new_tree)
49
+
50
+ new_source_code = ast.unparse(new_tree)
51
+
52
+ with open(file_path, "w", encoding="utf-8") as f:
53
+ f.write(new_source_code)
54
+ log_callback(f"Successfully updated {file_path.name} with new docstrings.")
55
+
56
+ except Exception as e:
57
+ log_callback(f"Error updating file {file_path.name}: {e}")
58
+
59
+ def update_module_docstring(file_path: Path, docstring: str, log_callback: Callable[[str], None] = print):
60
+ """
61
+ Parses a Python file, adds or replaces the module-level docstring, and overwrites the file.
62
+ """
63
+ try:
64
+ with open(file_path, "r", encoding="utf-8") as f:
65
+ source_code = f.read()
66
+
67
+ tree = ast.parse(source_code)
68
+
69
+ # Create the new docstring node
70
+ new_docstring_node = ast.Expr(value=ast.Constant(value=docstring))
71
+
72
+ # Check if a module docstring already exists
73
+ if ast.get_docstring(tree):
74
+ # Replace the existing docstring node
75
+ tree.body[0] = new_docstring_node
76
+ else:
77
+ # Insert the new docstring node at the beginning
78
+ tree.body.insert(0, new_docstring_node)
79
+
80
+ ast.fix_missing_locations(tree)
81
+ new_source_code = ast.unparse(tree)
82
+
83
+ with open(file_path, "w", encoding="utf-8") as f:
84
+ f.write(new_source_code)
85
+ log_callback(f"Successfully added/updated module docstring for {file_path.name}.")
86
+
87
+ except Exception as e:
88
+ log_callback(f"Error updating module docstring for {file_path.name}: {e}")
requirements.txt ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Existing
2
+ click
3
+ python-dotenv
4
+ google-generativeai
5
+ groq
6
+ networkx
7
+ GitPython
8
+
9
+ # New for Web Server
10
+ fastapi
11
+ uvicorn[standard]
12
+ python-multipart
13
+ aiohttp
14
+ sse-starlette
15
+ PyGithub
16
+ requests
run.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ # run.py
2
+ import uvicorn
3
+
4
+ if __name__ == "__main__":
5
+ uvicorn.run("server.main:app", host="0.0.0.0", port=8000, reload=True)
server/__init__.py ADDED
File without changes
server/main.py ADDED
@@ -0,0 +1,275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import os
3
+ import tempfile
4
+ import zipfile
5
+ import shutil
6
+ from pathlib import Path
7
+ import requests
8
+
9
+ from fastapi import FastAPI, UploadFile, File, Form, Request, HTTPException
10
+ from fastapi.responses import FileResponse, RedirectResponse, StreamingResponse
11
+ from fastapi.staticfiles import StaticFiles
12
+ from fastapi.middleware.cors import CORSMiddleware
13
+
14
+ from typing import List
15
+ from git import Repo
16
+
17
+ from github import Github, GithubException # Github is already imported
18
+
19
+ from .tasks import process_project
20
+
21
+ # Load environment variables
22
+ from dotenv import load_dotenv
23
+ load_dotenv()
24
+
25
+ GITHUB_CLIENT_ID = os.getenv("GITHUB_CLIENT_ID")
26
+ GITHUB_CLIENT_SECRET = os.getenv("GITHUB_CLIENT_SECRET")
27
+
28
+ app = FastAPI()
29
+
30
+ origins = [
31
+ "http://localhost",
32
+ "http://localhost:8000",
33
+ "http://127.0.0.1",
34
+ "http://12_7.0.0.1:8000",
35
+ ]
36
+
37
+ app.add_middleware(
38
+ CORSMiddleware,
39
+ allow_origins=origins,
40
+ allow_credentials=True,
41
+ allow_methods=["*"],
42
+ allow_headers=["*"],
43
+ )
44
+
45
+ app.mount("/static", StaticFiles(directory="static"), name="static")
46
+
47
+ @app.get("/")
48
+ async def read_root():
49
+ return FileResponse('static/index.html')
50
+
51
+ @app.get("/login/github")
52
+ async def login_github():
53
+ return RedirectResponse(
54
+ f"https://github.com/login/oauth/authorize?client_id={GITHUB_CLIENT_ID}&scope=repo",
55
+ status_code=302
56
+ )
57
+
58
+ @app.get("/auth/github/callback")
59
+ async def auth_github_callback(code: str, request: Request):
60
+ params = {
61
+ "client_id": GITHUB_CLIENT_ID,
62
+ "client_secret": GITHUB_CLIENT_SECRET,
63
+ "code": code,
64
+ }
65
+ headers = {"Accept": "application/json"}
66
+ base_url = str(request.base_url)
67
+ try:
68
+ response = requests.post("https://github.com/login/oauth/access_token", params=params, headers=headers)
69
+ response.raise_for_status()
70
+ response_json = response.json()
71
+ if "error" in response_json:
72
+ error_description = response_json.get("error_description", "Unknown error.")
73
+ return RedirectResponse(f"{base_url}?error={error_description}")
74
+ token = response_json.get("access_token")
75
+ if not token:
76
+ return RedirectResponse(f"{base_url}?error=Authentication failed, no token received.")
77
+ return RedirectResponse(f"{base_url}?token={token}")
78
+ except requests.exceptions.RequestException as e:
79
+ return RedirectResponse(f"{base_url}?error=Failed to connect to GitHub: {e}")
80
+
81
+ @app.get("/api/github/repos")
82
+ async def get_github_repos(request: Request):
83
+ auth_header = request.headers.get("Authorization")
84
+ if not auth_header or not auth_header.startswith("Bearer "):
85
+ raise HTTPException(status_code=401, detail="Unauthorized")
86
+ token = auth_header.split(" ")[1]
87
+ try:
88
+ g = Github(token)
89
+ user = g.get_user()
90
+ repos = [{"full_name": repo.full_name, "default_branch": repo.default_branch} for repo in user.get_repos(type='owner')]
91
+ return repos
92
+ except Exception as e:
93
+ raise HTTPException(status_code=400, detail=f"Failed to fetch repos: {e}")
94
+
95
+ @app.get("/api/github/branches")
96
+ async def get_github_repo_branches(request: Request, repo_full_name: str):
97
+ auth_header = request.headers.get("Authorization")
98
+ if not auth_header or not auth_header.startswith("Bearer "):
99
+ raise HTTPException(status_code=401, detail="Unauthorized")
100
+ token = auth_header.split(" ")[1]
101
+ try:
102
+ g = Github(token)
103
+ repo = g.get_repo(repo_full_name)
104
+ branches = [branch.name for branch in repo.get_branches()]
105
+ return branches
106
+ except GithubException as e:
107
+ raise HTTPException(status_code=e.status, detail=f"GitHub API error: {e.data.get('message', 'Could not fetch branches.')}")
108
+ except Exception as e:
109
+ raise HTTPException(status_code=500, detail=f"An unexpected error occurred while fetching branches: {e}")
110
+
111
+
112
+ @app.get("/api/github/tree")
113
+ async def get_github_repo_tree(request: Request, repo_full_name: str, branch: str):
114
+ auth_header = request.headers.get("Authorization")
115
+ if not auth_header or not auth_header.startswith("Bearer "):
116
+ raise HTTPException(status_code=401, detail="Unauthorized")
117
+ token = auth_header.split(" ")[1]
118
+ temp_dir = tempfile.mkdtemp(prefix="codescribe-tree-")
119
+ try:
120
+ repo_url = f"https://x-access-token:{token}@github.com/{repo_full_name}.git"
121
+ Repo.clone_from(repo_url, temp_dir, branch=branch, depth=1)
122
+ repo_path = Path(temp_dir)
123
+ tree = []
124
+ for root, dirs, files in os.walk(repo_path):
125
+ if '.git' in dirs:
126
+ dirs.remove('.git')
127
+ current_level = tree
128
+ rel_path = Path(root).relative_to(repo_path)
129
+ if str(rel_path) != ".":
130
+ for part in rel_path.parts:
131
+ parent = next((item for item in current_level if item['name'] == part), None)
132
+ if not parent: break
133
+ current_level = parent.get('children', [])
134
+ for d in sorted(dirs):
135
+ current_level.append({'name': d, 'children': []})
136
+ for f in sorted(files):
137
+ current_level.append({'name': f})
138
+ return tree
139
+ except Exception as e:
140
+ raise HTTPException(status_code=500, detail=f"Failed to clone or process repo tree: {e}")
141
+ finally:
142
+ shutil.rmtree(temp_dir, ignore_errors=True)
143
+
144
+ @app.get("/api/github/branch-exists")
145
+ async def check_branch_exists(request: Request, repo_full_name: str, branch_name: str):
146
+ auth_header = request.headers.get("Authorization")
147
+ if not auth_header or not auth_header.startswith("Bearer "):
148
+ raise HTTPException(status_code=401, detail="Unauthorized")
149
+ token = auth_header.split(" ")[1]
150
+
151
+ try:
152
+ g = Github(token)
153
+ repo = g.get_repo(repo_full_name)
154
+ # The get_branch method throws a 404 GithubException if not found
155
+ repo.get_branch(branch=branch_name)
156
+ return {"exists": True}
157
+ except GithubException as e:
158
+ if e.status == 404:
159
+ return {"exists": False}
160
+ # Re-raise for other errors like permissions, repo not found, etc.
161
+ raise HTTPException(status_code=e.status, detail=f"GitHub API error: {e.data.get('message', 'Unknown error')}")
162
+ except Exception as e:
163
+ raise HTTPException(status_code=500, detail=f"An unexpected error occurred: {e}")
164
+
165
+
166
+ @app.post("/process-zip")
167
+ async def process_zip_endpoint(
168
+ description: str = Form(...),
169
+ readme_note: str = Form(""),
170
+ zip_file: UploadFile = File(...),
171
+ exclude_patterns: str = Form("")
172
+ ):
173
+ exclude_list = [p.strip() for p in exclude_patterns.splitlines() if p.strip()]
174
+ temp_dir = tempfile.mkdtemp(prefix="codescribe-zip-")
175
+ project_path = Path(temp_dir)
176
+ zip_location = project_path / zip_file.filename
177
+
178
+ with open(zip_location, "wb+") as f:
179
+ shutil.copyfileobj(zip_file.file, f)
180
+ with zipfile.ZipFile(zip_location, 'r') as zip_ref:
181
+ zip_ref.extractall(project_path)
182
+ os.remove(zip_location)
183
+
184
+ stream_headers = {
185
+ "Content-Type": "text/plain",
186
+ "Cache-Control": "no-cache",
187
+ "Connection": "keep-alive",
188
+ "X-Accel-Buffering": "no",
189
+ }
190
+
191
+ # Create a placeholder repo name from the zip filename for the orchestrator
192
+ placeholder_repo_name = f"zip-upload/{Path(zip_file.filename).stem}"
193
+
194
+ return StreamingResponse(
195
+ process_project(
196
+ project_path=project_path,
197
+ description=description,
198
+ readme_note=readme_note,
199
+ is_temp=True,
200
+ exclude_list=exclude_list,
201
+ repo_full_name=placeholder_repo_name,
202
+ ),
203
+ headers=stream_headers,
204
+ media_type="text/plain"
205
+ )
206
+
207
+ @app.post("/process-github")
208
+ async def process_github_endpoint(request: Request,
209
+ repo_full_name: str = Form(...),
210
+ base_branch: str = Form(...),
211
+ new_branch_name: str = Form(...),
212
+ description: str = Form(...),
213
+ readme_note: str = Form(""),
214
+ exclude_patterns: str = Form(""),
215
+ exclude_paths: List[str] = Form([])
216
+ ):
217
+ auth_header = request.headers.get("Authorization")
218
+ if not auth_header or not auth_header.startswith("Bearer "):
219
+ raise HTTPException(status_code=401, detail="Unauthorized")
220
+ token = auth_header.split(" ")[1]
221
+
222
+ # --- Server-side Branch Existence Check (as a fallback) ---
223
+ try:
224
+ g = Github(token)
225
+ repo = g.get_repo(repo_full_name)
226
+ existing_branches = [b.name for b in repo.get_branches()]
227
+ if new_branch_name in existing_branches:
228
+ raise HTTPException(
229
+ status_code=409, # 409 Conflict is appropriate here
230
+ detail=f"Branch '{new_branch_name}' already exists. Please use a different name."
231
+ )
232
+ except GithubException as e:
233
+ raise HTTPException(status_code=404, detail=f"Repository '{repo_full_name}' not found or token lacks permissions: {e}")
234
+ except Exception as e:
235
+ raise HTTPException(status_code=500, detail=f"An error occurred while checking branches: {e}")
236
+ # --- END of check ---
237
+
238
+ regex_list = [p.strip() for p in exclude_patterns.splitlines() if p.strip()]
239
+ exclude_list = regex_list + exclude_paths
240
+
241
+ temp_dir = tempfile.mkdtemp(prefix="codescribe-git-")
242
+ project_path = Path(temp_dir)
243
+ repo_url = f"https://x-access-token:{token}@github.com/{repo_full_name}.git"
244
+
245
+ Repo.clone_from(repo_url, project_path, branch=base_branch)
246
+
247
+ stream_headers = {
248
+ "Content-Type": "text/plain",
249
+ "Cache-Control": "no-cache",
250
+ "Connection": "keep-alive",
251
+ "X-Accel-Buffering": "no",
252
+ }
253
+
254
+ return StreamingResponse(
255
+ process_project(
256
+ project_path=project_path,
257
+ description=description,
258
+ readme_note=readme_note,
259
+ is_temp=True,
260
+ new_branch_name=new_branch_name,
261
+ repo_full_name=repo_full_name,
262
+ github_token=token,
263
+ exclude_list=exclude_list,
264
+ ),
265
+ headers=stream_headers,
266
+ media_type="text/plain"
267
+ )
268
+
269
+ @app.get("/download/{file_path}")
270
+ async def download_file(file_path: str):
271
+ temp_dir = tempfile.gettempdir()
272
+ full_path = Path(temp_dir) / file_path
273
+ if not full_path.exists():
274
+ raise HTTPException(status_code=404, detail="File not found or expired.")
275
+ return FileResponse(path=full_path, filename=file_path, media_type='application/zip')
server/tasks.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # server/tasks.py
2
+
3
+ import os
4
+ import shutil
5
+ import tempfile
6
+ import zipfile
7
+ import json
8
+ import asyncio
9
+ from pathlib import Path
10
+ from typing import AsyncGenerator, List
11
+
12
+ from git import Repo, GitCommandError
13
+
14
+ from codescribe.config import load_config
15
+ from codescribe.llm_handler import LLMHandler
16
+ from codescribe.orchestrator import DocstringOrchestrator
17
+ from codescribe.readme_generator import ReadmeGenerator
18
+
19
+ async def process_project(
20
+ project_path: Path,
21
+ description: str,
22
+ readme_note: str,
23
+ is_temp: bool,
24
+ exclude_list: List[str],
25
+ new_branch_name: str = None,
26
+ repo_full_name: str = None,
27
+ github_token: str = None
28
+ ) -> AsyncGenerator[str, None]:
29
+
30
+ loop = asyncio.get_running_loop()
31
+ queue = asyncio.Queue()
32
+
33
+ def emit_event(event: str, data: dict):
34
+ """A thread-safe way to send events from the worker thread to the async generator."""
35
+ loop.call_soon_threadsafe(queue.put_nowait, {"event": event, "data": data})
36
+
37
+ def _blocking_process():
38
+ """The main, synchronous processing logic that runs in a separate thread."""
39
+ try:
40
+ # --- Setup ---
41
+ config = load_config()
42
+ llm_handler = LLMHandler(config.api_keys, progress_callback=lambda msg: emit_event("log", {"message": msg}))
43
+
44
+ # --- 1. Docstrings Phase (using the orchestrator) ---
45
+ doc_orchestrator = DocstringOrchestrator(
46
+ path_or_url=str(project_path),
47
+ description=description,
48
+ exclude=exclude_list,
49
+ llm_handler=llm_handler,
50
+ progress_callback=emit_event,
51
+ repo_full_name=repo_full_name # <<< THIS IS THE FIX
52
+ )
53
+ doc_orchestrator.project_path = project_path
54
+ doc_orchestrator.is_temp_dir = False
55
+ doc_orchestrator.run()
56
+
57
+ # --- 2. READMEs Phase (using the generator) ---
58
+ readme_gen = ReadmeGenerator(
59
+ path_or_url=str(project_path),
60
+ description=description,
61
+ exclude=exclude_list,
62
+ llm_handler=llm_handler,
63
+ user_note=readme_note,
64
+ progress_callback=emit_event,
65
+ repo_full_name=repo_full_name
66
+ )
67
+ readme_gen.project_path = project_path
68
+ readme_gen.is_temp_dir = False
69
+ readme_gen.run()
70
+
71
+ # --- 3. Output Phase ---
72
+ emit_event("phase", {"id": "output", "status": "in-progress"})
73
+ if new_branch_name:
74
+ # Git logic remains the same
75
+ emit_event("subtask", {"parentId": "output", "listId": "output-step-list", "id": "git-check", "name": "Checking for changes...", "status": "in-progress"})
76
+ repo = Repo(project_path)
77
+
78
+ if repo.untracked_files:
79
+ repo.git.add(repo.untracked_files)
80
+
81
+ if not repo.is_dirty(untracked_files=True):
82
+ emit_event("subtask", {"parentId": "output", "id": "git-check", "status": "success"})
83
+ emit_event("log", {"message": "No changes were generated by the AI."})
84
+ emit_event("done", {"type": "github", "url": f"https://github.com/{repo_full_name}", "message": "✅ No changes needed. Your repository is up to date."})
85
+ return
86
+
87
+ emit_event("subtask", {"parentId": "output", "id": "git-check", "status": "success"})
88
+ emit_event("subtask", {"parentId": "output", "listId": "output-step-list", "id": "git-push", "name": f"Pushing to branch '{new_branch_name}'...", "status": "in-progress"})
89
+
90
+ try:
91
+ if new_branch_name in repo.heads:
92
+ repo.heads[new_branch_name].checkout()
93
+ else:
94
+ repo.create_head(new_branch_name).checkout()
95
+
96
+ repo.git.add(A=True)
97
+ repo.index.commit("docs: Add AI-generated documentation by CodeScribe")
98
+
99
+ origin = repo.remote(name='origin')
100
+ origin.push(new_branch_name, force=True)
101
+
102
+ pr_url = f"https://github.com/{repo_full_name}/pull/new/{new_branch_name}"
103
+ emit_event("subtask", {"parentId": "output", "id": "git-push", "status": "success"})
104
+ emit_event("done", {"type": "github", "url": pr_url, "message": "✅ Successfully pushed changes!"})
105
+
106
+ except GitCommandError as e:
107
+ emit_event("log", {"message": f"Git Error: {e}"})
108
+ raise RuntimeError(f"Failed to push to GitHub: {e}")
109
+ else:
110
+ # ZIP logic remains the same
111
+ emit_event("subtask", {"parentId": "output", "listId": "output-step-list", "id": "zip-create", "name": "Creating downloadable ZIP file...", "status": "in-progress"})
112
+
113
+ temp_dir = tempfile.gettempdir()
114
+ try:
115
+ contained_dir = next(project_path.iterdir())
116
+ project_name = contained_dir.name
117
+ except StopIteration:
118
+ project_name = "documented-project"
119
+
120
+ zip_filename_base = f"codescribe-docs-{project_name}"
121
+ zip_path_base = Path(temp_dir) / zip_filename_base
122
+
123
+ zip_full_path = shutil.make_archive(str(zip_path_base), 'zip', project_path)
124
+ zip_file_name = Path(zip_full_path).name
125
+
126
+ emit_event("subtask", {"parentId": "output", "id": "zip-create", "status": "success"})
127
+ emit_event("done", {"type": "zip", "download_path": zip_file_name, "message": "✅ Your documented project is ready for download."})
128
+
129
+ emit_event("phase", {"id": "output", "status": "success"})
130
+
131
+ except Exception as e:
132
+ emit_event("error", str(e))
133
+ finally:
134
+ loop.call_soon_threadsafe(queue.put_nowait, None)
135
+
136
+ # Async runner remains the same
137
+ main_task = loop.run_in_executor(None, _blocking_process)
138
+ while True:
139
+ message = await queue.get()
140
+ if message is None:
141
+ break
142
+ json_line = json.dumps({"type": message["event"], "payload": message["data"]})
143
+ yield f"{json_line}\n"
144
+ await main_task
145
+ if is_temp and project_path and project_path.exists():
146
+ shutil.rmtree(project_path, ignore_errors=True)
static/index.html ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
6
+ <title>CodeScribe AI</title>
7
+ <link rel="stylesheet" href="/static/style.css"/>
8
+ <meta name="color-scheme" content="dark light"/>
9
+ </head>
10
+ <body>
11
+ <!-- Decorative Background -->
12
+ <div class="bg">
13
+ <div class="bg-gradient"></div>
14
+ <div class="bg-grid"></div>
15
+ <canvas id="bg-canvas" aria-hidden="true"></canvas>
16
+ </div>
17
+
18
+ <main class="shell">
19
+ <div class="container glass-card" data-animate>
20
+ <header class="app-header">
21
+ <div class="brand">
22
+ <div class="brand-mark" aria-hidden="true">
23
+ <span class="dot dot-1"></span>
24
+ <span class="dot dot-2"></span>
25
+ <span class="dot dot-3"></span>
26
+ </div>
27
+ <h1 class="title text-gradient">CodeScribe AI</h1>
28
+ </div>
29
+ <p class="subtitle">AI-powered project documentation, right in your browser.</p>
30
+ </header>
31
+
32
+ <!-- Auth -->
33
+ <section id="auth-section" class="panel" data-animate>
34
+ <button id="github-login-btn" class="btn btn-primary btn-glow" aria-label="Login with GitHub">
35
+ <span class="btn-inner">
36
+ <svg class="icon" width="20" height="20" viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="M8 .198a8 8 0 0 0-2.53 15.6c.4.073.547-.173.547-.385v-1.35c-2.226.483-2.695-1.073-2.695-1.073c-.364-.925-.89-1.172-.89-1.172c-.727-.497.055-.487.055-.487c.803.056 1.225.825 1.225.825c.715 1.224 1.874.87 2.33.665c.073-.518.28-.871.508-1.072C4.9 9.83 3 9.232 3 6.58c0-.87.31-1.58.824-2.136c-.083-.203-.357-1.02.078-2.127c0 0 .673-.215 2.206.817c.64-.18 1.325-.27 2.006-.273c.68.003 1.365.093 2.006.273c1.532-1.032 2.204-.817 2.204-.817c.437 1.107.163 1.924.08 2.127C12.69 5 13 5.71 13 6.58c0 2.66-1.902 3.248-3.714 3.419c.287.247.543.736.543 1.484v2.2c0 .214.145.462.55.384A8 8 0 0 0 8 .198"/></svg>
37
+ <span>Login with GitHub</span>
38
+ </span>
39
+ </button>
40
+ </section>
41
+
42
+ <!-- Main Content -->
43
+ <section id="main-content" class="hidden" data-animate>
44
+ <!-- Tabs -->
45
+ <div class="tabs">
46
+ <button id="select-zip-btn" class="tab-btn active" type="button">
47
+ <span>Upload Zip</span>
48
+ </button>
49
+ <button id="select-github-btn" class="tab-btn" type="button">
50
+ <span>Use GitHub Repo</span>
51
+ </button>
52
+ <span class="tab-indicator" aria-hidden="true"></span>
53
+ </div>
54
+
55
+ <!-- Form -->
56
+ <form id="doc-form" class="form">
57
+ GitHub Inputs
58
+ <div id="github-inputs" class="panel">
59
+ <div class="form-row">
60
+ <div class="form-group">
61
+ <label for="repo-select">Select a Repository</label>
62
+ <select id="repo-select" name="repo_full_name" required></select>
63
+ </div>
64
+ <div class="form-group">
65
+ <label for="base-branch-select">Base Branch</label>
66
+ <select id="base-branch-select" name="base_branch" required></select>
67
+ </div>
68
+ </div>
69
+ <div class="form-group">
70
+ <label for="new-branch-input">New Branch Name</label>
71
+ <input id="new-branch-input" type="text" name="new_branch_name" value="docs/codescribe-ai" required/>
72
+ <small id="branch-name-error" class="form-error" aria-live="polite" style="display:none;"></small>
73
+ </div>
74
+ </div>
75
+
76
+ <!-- ZIP Inputs -->
77
+ <div id="zip-inputs" class="panel hidden">
78
+ <div class="form-group">
79
+ <label for="zip-file">Upload Project as .zip</label>
80
+ <input id="zip-file" type="file" name="zip_file" accept=".zip" required/>
81
+ </div>
82
+ </div>
83
+
84
+ <div class="divider"></div>
85
+
86
+ <!-- Common Inputs -->
87
+ <div class="panel">
88
+ <div class="form-group">
89
+ <label for="description">Short Project Description</label>
90
+ <textarea id="description" name="description" rows="3" required placeholder="e.g., A Python library for advanced data analysis and visualization."></textarea>
91
+ </div>
92
+ <div class="form-group">
93
+ <label for="readme-note">Note for README (Optional)</label>
94
+ <textarea id="readme-note" name="readme_note" rows="2" placeholder="e.g., Emphasize the new data processing pipeline. Mention the v2.0 release."></textarea>
95
+ </div>
96
+ </div>
97
+
98
+ <div class="divider"></div>
99
+
100
+ <div class="panel">
101
+ <div class="form-group">
102
+ <label for="exclude-patterns">Exclude (Regex Patterns)</label>
103
+ <textarea id="exclude-patterns" name="exclude_patterns" rows="2" placeholder="e.g., tests/.*, .*/migrations/.*, specific_file.py"></textarea>
104
+ <small>One regex pattern per line. Matches relative paths like 'src/utils/helpers.py'.</small>
105
+ </div>
106
+
107
+ <div id="file-tree-container" class="form-group hidden">
108
+ <label>Exclude Specific Files/Folders</label>
109
+ <div id="file-tree" class="tree"></div>
110
+ </div>
111
+ </div>
112
+
113
+ <div class="form-actions">
114
+ <button id="submit-btn" type="submit" class="btn btn-primary btn-glow">
115
+ <span class="btn-inner">
116
+ <span class="loader" aria-hidden="true"></span>
117
+ <span>Generate Documentation</span>
118
+ </span>
119
+ </button>
120
+ </div>
121
+ </form>
122
+ </section>
123
+
124
+ <!-- Live Progress -->
125
+ <section id="live-progress-view" class="hidden" data-animate>
126
+ <h2 class="section-title">Processing Your Project...</h2>
127
+ <div class="progress-layout">
128
+ <ul id="phase-list" class="timeline">
129
+ <li id="phase-scan" class="phase-item" data-status="pending">
130
+ <span class="status-icon" aria-hidden="true"></span>
131
+ <div class="phase-details">
132
+ <span class="phase-title">1. Scan Project Files</span>
133
+ <ul class="subtask-list" id="scan-file-list"></ul>
134
+ </div>
135
+ </li>
136
+ <li id="phase-docstrings" class="phase-item" data-status="pending">
137
+ <span class="status-icon" aria-hidden="true"></span>
138
+ <div class="phase-details">
139
+ <span class="phase-title">2. Generate Docstrings</span>
140
+ <ul class="subtask-list" id="docstring-file-list"></ul>
141
+ <ul class="subtask-list" id="docstring-module-list"></ul>
142
+ <ul class="subtask-list" id="docstring-package-list"></ul>
143
+ </div>
144
+ </li>
145
+ <li id="phase-readmes" class="phase-item" data-status="pending">
146
+ <span class="status-icon" aria-hidden="true"></span>
147
+ <div class="phase-details">
148
+ <span class="phase-title">3. Generate READMEs</span>
149
+ <ul class="subtask-list" id="readme-dir-list"></ul>
150
+ </div>
151
+ </li>
152
+ <li id="phase-output" class="phase-item" data-status="pending">
153
+ <span class="status-icon" aria-hidden="true"></span>
154
+ <div class="phase-details">
155
+ <span class="phase-title">4. Finalize and Push</span>
156
+ <ul class="subtask-list" id="output-step-list"></ul>
157
+ </div>
158
+ </li>
159
+ </ul>
160
+
161
+ <div id="raw-log-container" class="terminal">
162
+ <div class="terminal-head">
163
+ <span class="dot red" aria-hidden="true"></span>
164
+ <span class="dot yellow" aria-hidden="true"></span>
165
+ <span class="dot green" aria-hidden="true"></span>
166
+ <h4>Live Log Details</h4>
167
+ </div>
168
+ <pre id="log-output" class="terminal-body" aria-live="polite"></pre>
169
+ </div>
170
+ </div>
171
+ </section>
172
+
173
+ <!-- Result -->
174
+ <section id="result-section" class="hidden" data-animate>
175
+ <h2 class="section-title success">Complete!</h2>
176
+ <div id="result-link" class="result-card"></div>
177
+ </section>
178
+ </div>
179
+ </main>
180
+
181
+ <script src="/static/script.js" defer></script>
182
+ </body>
183
+ </html>
static/script.js ADDED
@@ -0,0 +1,541 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ document.addEventListener("DOMContentLoaded", () => {
2
+ // --- Constants ---
3
+ const GITHUB_TOKEN_KEY = "github_access_token";
4
+
5
+ // --- DOM Element Cache ---
6
+ const docForm = document.getElementById("doc-form");
7
+ const submitBtn = document.getElementById("submit-btn");
8
+ const authSection = document.getElementById("auth-section");
9
+ const mainContent = document.getElementById("main-content");
10
+ const githubLoginBtn = document.getElementById("github-login-btn");
11
+ const selectZipBtn = document.getElementById("select-zip-btn");
12
+ const selectGithubBtn = document.getElementById("select-github-btn");
13
+ const zipInputs = document.getElementById("zip-inputs");
14
+ const githubInputs = document.getElementById("github-inputs");
15
+ const zipFileInput = document.getElementById("zip-file");
16
+ const repoSelect = document.getElementById("repo-select");
17
+ const baseBranchSelect = document.getElementById("base-branch-select");
18
+ const newBranchInput = document.getElementById("new-branch-input");
19
+ const branchNameError = document.getElementById("branch-name-error");
20
+ const fileTreeContainer = document.getElementById("file-tree-container");
21
+ const fileTree = document.getElementById("file-tree");
22
+ const liveProgressView = document.getElementById("live-progress-view");
23
+ const resultSection = document.getElementById("result-section");
24
+ const resultLink = document.getElementById("result-link");
25
+ const logOutput = document.getElementById("log-output");
26
+
27
+ // --- Helper Functions ---
28
+ const showView = (viewId) => {
29
+ [authSection, mainContent, liveProgressView, resultSection].forEach((el) =>
30
+ el.classList.add("hidden")
31
+ );
32
+ document.getElementById(viewId).classList.remove("hidden");
33
+ };
34
+ const sanitizeForId = (str) =>
35
+ `subtask-${str.replace(/[^a-zA-Z0-9-]/g, "-")}`;
36
+ const resetProgressView = () => {
37
+ document
38
+ .querySelectorAll(".phase-item")
39
+ .forEach((item) => (item.dataset.status = "pending"));
40
+ document
41
+ .querySelectorAll(".subtask-list")
42
+ .forEach((list) => (list.innerHTML = ""));
43
+ logOutput.textContent = "";
44
+ };
45
+ const createTreeHtml = (nodes, pathPrefix = "") => {
46
+ let html = "<ul>";
47
+ nodes.forEach((node) => {
48
+ const fullPath = pathPrefix ? `${pathPrefix}/${node.name}` : node.name;
49
+ const isDir = !!node.children;
50
+ html += `<li><input type="checkbox" name="exclude_paths" value="${fullPath}" id="cb-${fullPath}"> <label for="cb-${fullPath}"><strong>${
51
+ node.name
52
+ }${isDir ? "/" : ""}</strong></label>`;
53
+ if (isDir) html += createTreeHtml(node.children, fullPath);
54
+ html += "</li>";
55
+ });
56
+ html += "</ul>";
57
+ return html;
58
+ };
59
+
60
+ // --- Core Logic (unchanged app behavior) ---
61
+ const checkBranchName = async () => {
62
+ const repoFullName = repoSelect.value;
63
+ const branchName = newBranchInput.value.trim();
64
+ const token = localStorage.getItem(GITHUB_TOKEN_KEY);
65
+
66
+ branchNameError.textContent = "";
67
+ branchNameError.style.display = "none";
68
+
69
+ if (
70
+ !repoFullName ||
71
+ !branchName ||
72
+ !token ||
73
+ !selectGithubBtn.classList.contains("active")
74
+ ) {
75
+ submitBtn.disabled = false;
76
+ return;
77
+ }
78
+
79
+ submitBtn.disabled = true;
80
+ submitBtn
81
+ .querySelector(".btn-inner span:last-child")
82
+ ?.replaceWith(document.createTextNode("Checking branch..."));
83
+
84
+ try {
85
+ const response = await fetch(
86
+ `/api/github/branch-exists?repo_full_name=${encodeURIComponent(
87
+ repoFullName
88
+ )}&branch_name=${encodeURIComponent(branchName)}`,
89
+ {
90
+ headers: { Authorization: `Bearer ${token}` },
91
+ }
92
+ );
93
+
94
+ if (!response.ok) {
95
+ const errData = await response.json();
96
+ throw new Error(errData.detail || `Server error: ${response.status}`);
97
+ }
98
+
99
+ const data = await response.json();
100
+ if (data.exists) {
101
+ branchNameError.textContent = `Branch '${branchName}' already exists. Please choose another name.`;
102
+ branchNameError.style.display = "block";
103
+ submitBtn.disabled = true;
104
+ } else {
105
+ submitBtn.disabled = false;
106
+ }
107
+ } catch (error) {
108
+ console.error("Error checking branch name:", error);
109
+ branchNameError.textContent = `Could not verify branch name. ${error.message}`;
110
+ branchNameError.style.display = "block";
111
+ submitBtn.disabled = false; // Allow submission, server will catch it if it's a real issue.
112
+ } finally {
113
+ // Restore button text
114
+ const inner = submitBtn.querySelector(".btn-inner");
115
+ if (inner) {
116
+ inner.innerHTML =
117
+ '<span class="loader" aria-hidden="true"></span><span>Generate Documentation</span>';
118
+ }
119
+ }
120
+ };
121
+
122
+ const handleAuth = () => {
123
+ const urlParams = new URLSearchParams(window.location.search);
124
+ const token = urlParams.get("token");
125
+ const error = urlParams.get("error");
126
+
127
+ if (error) alert(`Authentication failed: ${error}`);
128
+ if (token && token !== "None")
129
+ localStorage.setItem(GITHUB_TOKEN_KEY, token);
130
+ window.history.replaceState({}, document.title, "/");
131
+
132
+ if (localStorage.getItem(GITHUB_TOKEN_KEY)) {
133
+ // --- Logged In User Flow ---
134
+ showView("main-content");
135
+ fetchGithubRepos();
136
+ switchMode("github");
137
+ } else {
138
+ // --- Logged Out User Flow (FIXED) ---
139
+ // 1. Always show the main form content, not the separate auth screen.
140
+ // This makes the form, including ZIP upload, always accessible.
141
+ showView("main-content");
142
+
143
+ // 2. The auth section with the login button should be visible.
144
+ // We manually un-hide it because showView is designed to show only one view.
145
+ authSection.classList.remove("hidden");
146
+
147
+ // 3. Default to ZIP mode and disable the GitHub tab.
148
+ switchMode("zip");
149
+ selectGithubBtn.disabled = true;
150
+ }
151
+ };
152
+
153
+ const fetchGithubRepos = async () => {
154
+ const token = localStorage.getItem(GITHUB_TOKEN_KEY);
155
+ if (!token) return;
156
+
157
+ try {
158
+ const response = await fetch("/api/github/repos", {
159
+ headers: { Authorization: `Bearer ${token}` },
160
+ });
161
+ if (response.status === 401) {
162
+ localStorage.removeItem(GITHUB_TOKEN_KEY);
163
+ alert("GitHub session expired. Please log in again.");
164
+ handleAuth();
165
+ return;
166
+ }
167
+ if (!response.ok) throw new Error("Failed to fetch repos");
168
+
169
+ const repos = await response.json();
170
+ repoSelect.innerHTML =
171
+ '<option value="">-- Select a repository --</option>';
172
+ repos.forEach((repo) => {
173
+ const option = document.createElement("option");
174
+ option.value = repo.full_name;
175
+ option.textContent = repo.full_name;
176
+ option.dataset.defaultBranch = repo.default_branch;
177
+ repoSelect.appendChild(option);
178
+ });
179
+ } catch (error) {
180
+ console.error(error);
181
+ }
182
+ };
183
+
184
+ const fetchRepoBranches = async (repoFullName, defaultBranch) => {
185
+ const token = localStorage.getItem(GITHUB_TOKEN_KEY);
186
+ if (!token || !repoFullName) return;
187
+
188
+ baseBranchSelect.innerHTML = "<option>Loading branches...</option>";
189
+ baseBranchSelect.disabled = true;
190
+
191
+ try {
192
+ const response = await fetch(
193
+ `/api/github/branches?repo_full_name=${repoFullName}`,
194
+ {
195
+ headers: { Authorization: `Bearer ${token}` },
196
+ }
197
+ );
198
+ if (!response.ok) throw new Error("Failed to fetch branches");
199
+ const branches = await response.json();
200
+
201
+ baseBranchSelect.innerHTML = "";
202
+ branches.forEach((branchName) => {
203
+ const option = document.createElement("option");
204
+ option.value = branchName;
205
+ option.textContent = branchName;
206
+ if (branchName === defaultBranch) option.selected = true;
207
+ baseBranchSelect.appendChild(option);
208
+ });
209
+ } catch (error) {
210
+ console.error(error);
211
+ baseBranchSelect.innerHTML = `<option>Error loading branches</option>`;
212
+ } finally {
213
+ baseBranchSelect.disabled = false;
214
+ }
215
+ };
216
+
217
+ const fetchAndBuildTree = async (repoFullName, branch) => {
218
+ fileTreeContainer.classList.remove("hidden");
219
+ fileTree.innerHTML = "<em>Loading repository file tree...</em>";
220
+ const token = localStorage.getItem(GITHUB_TOKEN_KEY);
221
+ if (!token || !repoFullName || !branch) return;
222
+
223
+ try {
224
+ const response = await fetch(
225
+ `/api/github/tree?repo_full_name=${repoFullName}&branch=${branch}`,
226
+ {
227
+ headers: { Authorization: `Bearer ${token}` },
228
+ }
229
+ );
230
+ if (!response.ok)
231
+ throw new Error(
232
+ `Failed to fetch file tree (status: ${response.status})`
233
+ );
234
+ const treeData = await response.json();
235
+ fileTree.innerHTML = createTreeHtml(treeData);
236
+ } catch (error) {
237
+ console.error(error);
238
+ fileTree.innerHTML = `<em style="color: #ef4444;">${error.message}</em>`;
239
+ }
240
+ };
241
+
242
+ // --- In script.js ---
243
+
244
+ const switchMode = (mode) => {
245
+ fileTreeContainer.classList.add("hidden");
246
+ if (mode === "github") {
247
+ selectGithubBtn.classList.add("active");
248
+ selectZipBtn.classList.remove("active");
249
+ githubInputs.classList.remove("hidden");
250
+ zipInputs.classList.add("hidden");
251
+ // GitHub Mode: ZIP is not required, GitHub fields are.
252
+ zipFileInput.required = false;
253
+ repoSelect.required = true;
254
+ baseBranchSelect.required = true;
255
+ newBranchInput.required = true;
256
+ if (repoSelect.value)
257
+ fetchAndBuildTree(repoSelect.value, baseBranchSelect.value);
258
+ } else { // This is ZIP mode
259
+ selectZipBtn.classList.add("active");
260
+ selectGithubBtn.classList.remove("active");
261
+ zipInputs.classList.remove("hidden");
262
+ githubInputs.classList.add("hidden");
263
+ // ZIP Mode: ZIP is required, GitHub fields are not.
264
+ zipFileInput.required = true;
265
+ repoSelect.required = false;
266
+ baseBranchSelect.required = false; // <-- FIX: Added this line
267
+ newBranchInput.required = false; // <-- FIX: Added this line
268
+ }
269
+ };
270
+
271
+ const handleFormSubmit = (e) => {
272
+ e.preventDefault();
273
+ resetProgressView();
274
+ showView("live-progress-view");
275
+ submitBtn.disabled = true;
276
+
277
+ const formData = new FormData(docForm);
278
+ const endpoint = selectGithubBtn.classList.contains("active")
279
+ ? "/process-github"
280
+ : "/process-zip";
281
+
282
+ const headers = new Headers();
283
+ if (endpoint === "/process-github") {
284
+ headers.append(
285
+ "Authorization",
286
+ `Bearer ${localStorage.getItem(GITHUB_TOKEN_KEY)}`
287
+ );
288
+ }
289
+
290
+ const options = { method: "POST", body: formData, headers: headers };
291
+
292
+ fetch(endpoint, options)
293
+ .then((response) => {
294
+ if (!response.ok) {
295
+ return response.json().then((errData) => {
296
+ throw new Error(
297
+ `Server error: ${response.status} - ${
298
+ errData.detail || "Unknown error"
299
+ }`
300
+ );
301
+ });
302
+ }
303
+ const reader = response.body.getReader();
304
+ const decoder = new TextDecoder();
305
+ let buffer = "";
306
+
307
+ function push() {
308
+ reader
309
+ .read()
310
+ .then(({ done, value }) => {
311
+ if (done) {
312
+ if (buffer) {
313
+ try {
314
+ const json = JSON.parse(buffer);
315
+ handleStreamEvent(json.type, json.payload);
316
+ } catch (e) {
317
+ console.error(
318
+ "Error parsing final buffer chunk:",
319
+ buffer,
320
+ e
321
+ );
322
+ }
323
+ }
324
+ return;
325
+ }
326
+ buffer += decoder.decode(value, { stream: true });
327
+ const lines = buffer.split("\n");
328
+ buffer = lines.pop();
329
+ for (const line of lines) {
330
+ if (line.trim() === "") continue;
331
+ try {
332
+ const json = JSON.parse(line);
333
+ handleStreamEvent(json.type, json.payload);
334
+ } catch (e) {
335
+ console.error("Failed to parse JSON line:", line, e);
336
+ }
337
+ }
338
+ push();
339
+ })
340
+ .catch((err) => {
341
+ console.error("Stream reading error:", err);
342
+ handleStreamEvent("error", `Stream error: ${err.message}`);
343
+ });
344
+ }
345
+ push();
346
+ })
347
+ .catch((err) => handleStreamEvent("error", `${err.message}`));
348
+ };
349
+
350
+ const handleStreamEvent = (type, payload) => {
351
+ switch (type) {
352
+ case "phase": {
353
+ const phaseEl = document.getElementById(`phase-${payload.id}`);
354
+ if (phaseEl) phaseEl.dataset.status = payload.status;
355
+ break;
356
+ }
357
+ case "subtask": {
358
+ const subtaskId = sanitizeForId(payload.id);
359
+ let subtaskEl = document.getElementById(subtaskId);
360
+ const listEl = document.getElementById(payload.listId);
361
+ if (!subtaskEl && listEl) {
362
+ subtaskEl = document.createElement("li");
363
+ subtaskEl.id = subtaskId;
364
+ subtaskEl.textContent = payload.name;
365
+ listEl.appendChild(subtaskEl);
366
+ }
367
+ if (subtaskEl) subtaskEl.dataset.status = payload.status;
368
+ break;
369
+ }
370
+ case "log": {
371
+ logOutput.textContent += payload.message.replace(/\\n/g, "\n") + "\n";
372
+ logOutput.scrollTop = logOutput.scrollHeight;
373
+ break;
374
+ }
375
+ case "error": {
376
+ document
377
+ .querySelectorAll('.phase-item[data-status="in-progress"]')
378
+ .forEach((el) => (el.dataset.status = "error"));
379
+ logOutput.textContent += `\n\n--- ERROR ---\n${payload}\n`;
380
+ submitBtn.disabled = false;
381
+ break;
382
+ }
383
+ case "done": {
384
+ showView("result-section");
385
+ resultLink.innerHTML = `<p>${payload.message}</p>`;
386
+ if (payload.type === "zip") {
387
+ resultLink.innerHTML += `<a href="/download/${payload.download_path}" class="button-link" download>Download ZIP</a>`;
388
+ } else if (payload.url) {
389
+ const linkText = payload.url.includes("/pull/new/")
390
+ ? "Create Pull Request"
391
+ : "View Repository";
392
+ resultLink.innerHTML += `<a href="${payload.url}" target="_blank" rel="noopener noreferrer" class="button-link">${linkText}</a>`;
393
+ }
394
+ submitBtn.disabled = false;
395
+ break;
396
+ }
397
+ }
398
+ };
399
+
400
+ // --- Events ---
401
+ githubLoginBtn.addEventListener(
402
+ "click",
403
+ () => (window.location.href = "/login/github")
404
+ );
405
+ selectZipBtn.addEventListener("click", () => switchMode("zip"));
406
+ selectGithubBtn.addEventListener("click", () => switchMode("github"));
407
+ docForm.addEventListener("submit", handleFormSubmit);
408
+
409
+ repoSelect.addEventListener("change", async (e) => {
410
+ const selectedOption = e.target.options[e.target.selectedIndex];
411
+ const repoFullName = selectedOption.value;
412
+ const defaultBranch = selectedOption.dataset.defaultBranch || "";
413
+
414
+ if (repoFullName) {
415
+ await fetchRepoBranches(repoFullName, defaultBranch);
416
+ fetchAndBuildTree(repoFullName, baseBranchSelect.value);
417
+ checkBranchName();
418
+ } else {
419
+ baseBranchSelect.innerHTML = "";
420
+ fileTreeContainer.classList.add("hidden");
421
+ }
422
+ });
423
+
424
+ baseBranchSelect.addEventListener("change", () => {
425
+ fetchAndBuildTree(repoSelect.value, baseBranchSelect.value);
426
+ });
427
+
428
+ newBranchInput.addEventListener("blur", checkBranchName);
429
+
430
+ // Initialize
431
+ handleAuth();
432
+
433
+ // --- UI Enhancements: glow cursor on buttons ---
434
+ document.querySelectorAll(".btn-glow").forEach((btn) => {
435
+ btn.addEventListener("pointermove", (e) => {
436
+ const rect = btn.getBoundingClientRect();
437
+ const x = ((e.clientX - rect.left) / rect.width) * 100;
438
+ const y = ((e.clientY - rect.top) / rect.height) * 100;
439
+ btn.style.setProperty("--x", x + "%");
440
+ btn.style.setProperty("--y", y + "%");
441
+ });
442
+ });
443
+
444
+ // --- Ripple effect for buttons and links ---
445
+ function addRipple(e) {
446
+ const el = e.currentTarget;
447
+ const rect = el.getBoundingClientRect();
448
+ const circle = document.createElement("span");
449
+ circle.className = "ripple";
450
+ circle.style.left = `${e.clientX - rect.left}px`;
451
+ circle.style.top = `${e.clientY - rect.top}px`;
452
+ el.appendChild(circle);
453
+ setTimeout(() => circle.remove(), 600);
454
+ }
455
+ document.querySelectorAll("button.btn, .button-link").forEach((el) => {
456
+ el.addEventListener("click", addRipple);
457
+ });
458
+
459
+ // --- Subtle parallax tilt on hover for panels and phase items ---
460
+ const tiltEls = document.querySelectorAll(
461
+ ".panel, .phase-item, .terminal, .glass-card"
462
+ );
463
+ tiltEls.forEach((el) => {
464
+ let enter = false;
465
+ el.addEventListener("pointerenter", () => {
466
+ enter = true;
467
+ });
468
+ el.addEventListener("pointerleave", () => {
469
+ enter = false;
470
+ el.style.transform = "";
471
+ });
472
+ el.addEventListener("pointermove", (e) => {
473
+ if (!enter) return;
474
+ const rect = el.getBoundingClientRect();
475
+ const cx = rect.left + rect.width / 2;
476
+ const cy = rect.top + rect.height / 2;
477
+ const dx = (e.clientX - cx) / rect.width;
478
+ const dy = (e.clientY - cy) / rect.height;
479
+ const max = 6;
480
+ el.style.transform = `rotateX(${(-dy * max).toFixed(2)}deg) rotateY(${(
481
+ dx * max
482
+ ).toFixed(2)}deg) translateZ(0)`;
483
+ });
484
+ });
485
+
486
+ // --- Optional: lightweight animated background canvas (respects reduced motion) ---
487
+ const prefersReduced = window.matchMedia(
488
+ "(prefers-reduced-motion: reduce)"
489
+ ).matches;
490
+ const canvas = document.getElementById("bg-canvas");
491
+ if (canvas && !prefersReduced) {
492
+ const ctx = canvas.getContext("2d", { alpha: true });
493
+ let w, h, dots;
494
+ function resize() {
495
+ w = canvas.width = window.innerWidth;
496
+ h = canvas.height = window.innerHeight;
497
+ dots = Array.from(
498
+ { length: Math.min(90, Math.floor((w * h) / 60000)) },
499
+ () => ({
500
+ x: Math.random() * w,
501
+ y: Math.random() * h,
502
+ vx: (Math.random() - 0.5) * 0.4,
503
+ vy: (Math.random() - 0.5) * 0.4,
504
+ })
505
+ );
506
+ }
507
+ function step() {
508
+ ctx.clearRect(0, 0, w, h);
509
+ ctx.fillStyle = "rgba(34, 211, 238, 0.6)";
510
+ const threshold = 120;
511
+ for (let i = 0; i < dots.length; i++) {
512
+ const a = dots[i];
513
+ a.x += a.vx;
514
+ a.y += a.vy;
515
+ if (a.x < 0 || a.x > w) a.vx *= -1;
516
+ if (a.y < 0 || a.y > h) a.vy *= -1;
517
+ ctx.beginPath();
518
+ ctx.arc(a.x, a.y, 1.2, 0, Math.PI * 2);
519
+ ctx.fill();
520
+ for (let j = i + 1; j < dots.length; j++) {
521
+ const b = dots[j];
522
+ const dx = a.x - b.x,
523
+ dy = a.y - b.y;
524
+ const dist = Math.hypot(dx, dy);
525
+ if (dist < threshold) {
526
+ const alpha = (1 - dist / threshold) * 0.2;
527
+ ctx.strokeStyle = `rgba(96,165,250,${alpha})`;
528
+ ctx.beginPath();
529
+ ctx.moveTo(a.x, a.y);
530
+ ctx.lineTo(b.x, b.y);
531
+ ctx.stroke();
532
+ }
533
+ }
534
+ }
535
+ requestAnimationFrame(step);
536
+ }
537
+ window.addEventListener("resize", resize);
538
+ resize();
539
+ step();
540
+ }
541
+ });
static/style.css ADDED
@@ -0,0 +1,406 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ :root {
3
+ --bg: #0b1220;
4
+ --bg-2: #0d1528;
5
+ --panel: rgba(16, 24, 40, 0.72);
6
+ --panel-solid: #101828;
7
+ --card: rgba(17, 24, 39, 0.7);
8
+ --border: rgba(148, 163, 184, 0.16);
9
+ --text: #e2e8f0;
10
+ --muted: #94a3b8;
11
+ --primary: #22d3ee; /* cyan */
12
+ --primary-2: #06b6d4;
13
+ --success: #22c55e;
14
+ --danger: #ef4444;
15
+ --warning: #f59e0b;
16
+ --shadow: 0 10px 30px rgba(2, 8, 23, 0.6);
17
+ --radius: 14px;
18
+ --radius-sm: 10px;
19
+ --radius-lg: 20px;
20
+ --ring: 0 0 0 2px rgba(34, 211, 238, 0.35), 0 0 0 6px rgba(34, 211, 238, 0.15);
21
+ }
22
+
23
+ * { box-sizing: border-box; }
24
+ html, body { height: 100%; }
25
+ body {
26
+ margin: 0;
27
+ color: var(--text);
28
+ background: var(--bg);
29
+ font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, Apple Color Emoji, Segoe UI Emoji;
30
+ -webkit-font-smoothing: antialiased;
31
+ -moz-osx-font-smoothing: grayscale;
32
+ }
33
+
34
+ /* Background */
35
+ .bg {
36
+ position: fixed; inset: 0; overflow: hidden; z-index: -1;
37
+ }
38
+ .bg-gradient {
39
+ position: absolute; inset: -20%;
40
+ background:
41
+ radial-gradient(60% 60% at 10% 10%, rgba(34,211,238,0.12) 0%, transparent 60%),
42
+ radial-gradient(40% 40% at 90% 20%, rgba(59,130,246,0.12) 0%, transparent 60%),
43
+ radial-gradient(50% 50% at 50% 100%, rgba(16,185,129,0.1) 0%, transparent 60%);
44
+ filter: blur(40px);
45
+ animation: bgShift 20s ease-in-out infinite alternate;
46
+ }
47
+ @keyframes bgShift {
48
+ 0% { transform: translateY(-2%) scale(1); }
49
+ 100% { transform: translateY(2%) scale(1.04); }
50
+ }
51
+ .bg-grid {
52
+ position: absolute; inset: 0;
53
+ background-image:
54
+ linear-gradient(to right, rgba(148,163,184,0.08) 1px, transparent 1px),
55
+ linear-gradient(to bottom, rgba(148,163,184,0.08) 1px, transparent 1px);
56
+ background-size: 40px 40px, 40px 40px;
57
+ mask-image: radial-gradient(ellipse at center, #000 60%, transparent 100%);
58
+ animation: gridPan 30s linear infinite;
59
+ }
60
+ @keyframes gridPan {
61
+ from { background-position: 0 0, 0 0; }
62
+ to { background-position: 40px 40px, 40px 40px; }
63
+ }
64
+
65
+ /* Layout */
66
+ .shell {
67
+ min-height: 100svh;
68
+ display: grid;
69
+ place-items: center;
70
+ padding: 24px;
71
+ }
72
+ .container {
73
+ width: 100%;
74
+ max-width: 980px;
75
+ background: var(--panel);
76
+ border: 1px solid var(--border);
77
+ border-radius: var(--radius);
78
+ box-shadow: var(--shadow);
79
+ backdrop-filter: saturate(140%) blur(12px);
80
+ padding: 28px;
81
+ }
82
+ .glass-card[data-animate] {
83
+ opacity: 0; transform: translateY(8px);
84
+ animation: fadeInUp 600ms ease forwards;
85
+ }
86
+ [data-animate].hidden { opacity: 0 !important; transform: none; animation: none !important; }
87
+ @keyframes fadeInUp {
88
+ 0% { opacity: 0; transform: translateY(10px) scale(.98); }
89
+ 100% { opacity: 1; transform: translateY(0) scale(1); }
90
+ }
91
+ .panel {
92
+ background: linear-gradient(180deg, rgba(15,23,42,0.6), rgba(15,23,42,0.35));
93
+ border: 1px solid var(--border);
94
+ border-radius: var(--radius-sm);
95
+ padding: 16px;
96
+ }
97
+
98
+ /* Header */
99
+ .app-header { text-align: center; margin-bottom: 16px; }
100
+ .brand {
101
+ display: flex; align-items: center; justify-content: center; gap: 12px;
102
+ }
103
+ .brand-mark {
104
+ position: relative; width: 36px; height: 36px;
105
+ background: radial-gradient(circle at 30% 30%, var(--primary), transparent 60%),
106
+ radial-gradient(circle at 70% 70%, #60a5fa, transparent 60%);
107
+ border-radius: 12px;
108
+ border: 1px solid var(--border);
109
+ box-shadow: inset 0 0 20px rgba(34,211,238,0.15), 0 10px 20px rgba(2,8,23,0.6);
110
+ overflow: visible;
111
+ }
112
+ .brand-mark .dot {
113
+ position: absolute; width: 6px; height: 6px; border-radius: 50%;
114
+ background: var(--text); opacity: .7; filter: blur(.3px);
115
+ }
116
+ .dot-1 { top: 6px; left: 6px; animation: float 3.6s ease-in-out infinite; }
117
+ .dot-2 { bottom: 8px; right: 8px; animation: float 3.2s 250ms ease-in-out infinite; }
118
+ .dot-3 { top: 8px; right: 6px; animation: float 3.8s 400ms ease-in-out infinite; }
119
+ @keyframes float {
120
+ 0%,100% { transform: translateY(0); }
121
+ 50% { transform: translateY(-3px); }
122
+ }
123
+ .title {
124
+ margin: 0;
125
+ font-size: clamp(28px, 3.8vw, 40px);
126
+ letter-spacing: -.02em;
127
+ }
128
+ .text-gradient {
129
+ background: linear-gradient(90deg, #e2e8f0 0%, var(--primary) 50%, #60a5fa 100%);
130
+ -webkit-background-clip: text; background-clip: text; color: transparent;
131
+ }
132
+ .subtitle {
133
+ margin: 8px auto 0;
134
+ color: var(--muted);
135
+ max-width: 48ch;
136
+ }
137
+
138
+ /* Tabs */
139
+ .tabs {
140
+ position: relative;
141
+ display: grid;
142
+ grid-template-columns: 1fr 1fr;
143
+ gap: 8px;
144
+ background: rgba(148,163,184,0.08);
145
+ border: 1px solid var(--border);
146
+ border-radius: 999px;
147
+ padding: 6px;
148
+ margin: 18px 0 12px;
149
+ }
150
+ .tab-btn {
151
+ position: relative;
152
+ border: none;
153
+ background: transparent;
154
+ color: var(--muted);
155
+ padding: 10px 14px;
156
+ border-radius: 999px;
157
+ cursor: pointer;
158
+ font-weight: 600;
159
+ transition: color .2s ease;
160
+ z-index: 1;
161
+ }
162
+ .tab-btn.active { color: var(--text); }
163
+ .tab-indicator {
164
+ position: absolute; top: 6px; bottom: 6px; width: calc(50% - 6px);
165
+ background: linear-gradient(180deg, rgba(34,211,238,0.16), rgba(59,130,246,0.12));
166
+ border: 1px solid var(--border);
167
+ border-radius: 999px;
168
+ box-shadow: inset 0 0 0 1px rgba(255,255,255,0.04), 0 6px 18px rgba(2,8,23,0.6);
169
+ transition: transform .35s cubic-bezier(.2,.8,.2,1);
170
+ transform: translateX(0);
171
+ }
172
+ .tab-btn.active ~ .tab-indicator { transform: translateX(0); }
173
+ #select-github-btn.tab-btn.active ~ .tab-indicator { transform: translateX(100%); }
174
+
175
+ /* Form */
176
+ .form { display: grid; gap: 12px; }
177
+ .form-row { display: grid; gap: 12px; grid-template-columns: 1fr; }
178
+ @media (min-width: 700px){ .form-row { grid-template-columns: 1fr 1fr; } }
179
+
180
+ .form-group { display: grid; gap: 8px; }
181
+ label { font-weight: 600; color: var(--text); }
182
+ small { color: var(--muted); }
183
+
184
+ input[type="text"],
185
+ input[type="file"],
186
+ select,
187
+ textarea {
188
+ width: 100%;
189
+ padding: 12px 12px;
190
+ border-radius: 10px;
191
+ border: 1px solid var(--border);
192
+ color: var(--text);
193
+ background: linear-gradient(180deg, rgba(14,22,38,0.9), rgba(14,22,38,0.6));
194
+ outline: none;
195
+ transition: box-shadow .2s ease, border-color .2s ease, transform .08s ease;
196
+ }
197
+ textarea { resize: vertical; min-height: 92px; }
198
+ input:focus, select:focus, textarea:focus {
199
+ border-color: rgba(34,211,238,0.55);
200
+ box-shadow: var(--ring);
201
+ }
202
+
203
+ select{background: var(--bg);}
204
+ option {color: var(--primary); background-color: var(--bg-2);}
205
+
206
+ .form-error { color: var(--danger); font-weight: 600; }
207
+
208
+ /* Tree */
209
+ .tree {
210
+ background: linear-gradient(180deg, rgba(14,22,38,0.9), rgba(14,22,38,0.5));
211
+ border: 1px solid var(--border);
212
+ border-radius: 10px;
213
+ padding: 12px;
214
+ max-height: 320px;
215
+ overflow: auto;
216
+ }
217
+ #file-tree ul { list-style: none; padding-left: 1rem; margin: 6px 0; }
218
+ #file-tree label { font-weight: 500; }
219
+
220
+ /* Divider */
221
+ .divider {
222
+ height: 1px; width: 100%;
223
+ background: linear-gradient(90deg, transparent, rgba(148,163,184,0.2), transparent);
224
+ margin: 6px 0;
225
+ }
226
+
227
+ /* Buttons */
228
+ .btn {
229
+ --inset: inset 0 0 0 1px rgba(255,255,255,0.06);
230
+ display: inline-flex; align-items: center; justify-content: center;
231
+ gap: 10px;
232
+ border: none;
233
+ border-radius: 12px;
234
+ padding: 12px 16px;
235
+ font-weight: 700;
236
+ cursor: pointer;
237
+ background: linear-gradient(180deg, #0ea5b7, #0891a6);
238
+ color: white;
239
+ box-shadow: var(--inset), 0 8px 24px rgba(6, 182, 212, 0.25);
240
+ transition: transform .08s ease, box-shadow .2s ease, filter .2s ease;
241
+ }
242
+ .btn:hover { transform: translateY(-1px); filter: brightness(1.05); }
243
+ .btn:active { transform: translateY(0); filter: brightness(.98); }
244
+ .btn:disabled { opacity: .6; cursor: not-allowed; filter: grayscale(.2); }
245
+ .btn-primary { background: linear-gradient(180deg, var(--primary), var(--primary-2)); }
246
+ .btn-inner { display: inline-flex; align-items: center; gap: 10px; }
247
+ .icon { opacity: .9; }
248
+
249
+ /* Button glow + ripple */
250
+ .btn-glow { position: relative; overflow: hidden; }
251
+ .btn-glow::after {
252
+ content: ""; position: absolute; inset: -2px;
253
+ background: radial-gradient(120px 120px at var(--x,50%) var(--y,50%), rgba(255,255,255,0.18), transparent 40%);
254
+ transition: opacity .2s ease;
255
+ opacity: 0; pointer-events: none;
256
+ }
257
+ .btn-glow:hover::after { opacity: 1; }
258
+ .ripple {
259
+ position: absolute; border-radius: 50%; transform: translate(-50%, -50%);
260
+ background: rgba(255,255,255,0.35);
261
+ animation: ripple .6s ease-out forwards;
262
+ pointer-events: none;
263
+ }
264
+ @keyframes ripple {
265
+ from { width: 0; height: 0; opacity: .45; }
266
+ to { width: 360px; height: 360px; opacity: 0; }
267
+ }
268
+
269
+ /* Loader dot */
270
+ .loader {
271
+ width: 0.85em; height: 0.85em; border-radius: 50%;
272
+ background: currentColor; opacity: 0; transform: scale(.6);
273
+ box-shadow: 16px 0 0 currentColor, -16px 0 0 currentColor;
274
+ filter: drop-shadow(0 0 8px rgba(34,211,238,0.5));
275
+ }
276
+ button[disabled] .loader { opacity: .9; animation: pulseDots 1s infinite ease-in-out; }
277
+ @keyframes pulseDots {
278
+ 0%, 100% { box-shadow: 16px 0 0 currentColor, -16px 0 0 currentColor; }
279
+ 50% { box-shadow: 0 0 0 currentColor, 0 0 0 currentColor; }
280
+ }
281
+
282
+ /* Actions */
283
+ .form-actions { display: flex; justify-content: center; margin-top: 8px; }
284
+
285
+ /* Progress Layout */
286
+ .section-title { margin: 0 0 10px; }
287
+ .section-title.success { color: var(--success); }
288
+
289
+ .progress-layout {
290
+ display: grid; gap: 16px;
291
+ }
292
+ @media (min-width: 960px){
293
+ .progress-layout { grid-template-columns: 1.1fr .9fr; align-items: start; }
294
+ }
295
+
296
+ /* Timeline */
297
+ .timeline {
298
+ list-style: none; margin: 0; padding: 0;
299
+ display: grid; gap: 12px;
300
+ }
301
+ .phase-item {
302
+ display: grid; grid-template-columns: 28px 1fr; gap: 12px;
303
+ background: linear-gradient(180deg, rgba(15,23,42,0.6), rgba(15,23,42,0.35));
304
+ border: 1px solid var(--border);
305
+ border-radius: 12px;
306
+ padding: 12px;
307
+ transition: transform .2s ease, border-color .2s ease, background .3s ease, box-shadow .3s ease;
308
+ will-change: transform;
309
+ }
310
+ .phase-item:hover { transform: translateY(-2px); border-color: rgba(34,211,238,0.35); box-shadow: 0 8px 20px rgba(2,8,23,0.6); }
311
+ .phase-title { font-weight: 700; }
312
+
313
+ .status-icon {
314
+ position: relative; width: 24px; height: 24px; border-radius: 50%;
315
+ background: #334155; align-self: start;
316
+ box-shadow: inset 0 0 0 2px rgba(15,23,42,0.7);
317
+ }
318
+ .phase-item[data-status="pending"] .status-icon { background: #475569; opacity: .8; }
319
+ .phase-item[data-status="in-progress"] .status-icon {
320
+ background: linear-gradient(180deg, #60a5fa, #3b82f6);
321
+ animation: spin 1.2s linear infinite;
322
+ box-shadow: 0 0 0 6px rgba(59,130,246,0.15);
323
+ }
324
+ .phase-item[data-status="success"] .status-icon {
325
+ background: linear-gradient(180deg, #34d399, #22c55e);
326
+ box-shadow: 0 0 0 6px rgba(34,197,94,0.12);
327
+ }
328
+ .phase-item[data-status="error"] .status-icon {
329
+ background: linear-gradient(180deg, #f97373, #ef4444);
330
+ box-shadow: 0 0 0 6px rgba(239,68,68,0.15);
331
+ }
332
+
333
+ .phase-item[data-status="success"] .status-icon::after,
334
+ .phase-item[data-status="error"] .status-icon::after {
335
+ position: absolute; content: "";
336
+ inset: 5px; border-radius: 50%; background: rgba(0,0,0,0.2);
337
+ mask: center / contain no-repeat;
338
+ }
339
+ .phase-item[data-status="success"] .status-icon::after {
340
+ mask-image: radial-gradient(circle at 50% 58%, transparent 56%, black 56.5%),
341
+ linear-gradient(transparent 45%, black 45% 55%, transparent 55%);
342
+ }
343
+ .phase-item[data-status="error"] .status-icon::after {
344
+ mask-image: linear-gradient(transparent 45%, black 45% 55%, transparent 55%),
345
+ linear-gradient(90deg, transparent 45%, black 45% 55%, transparent 55%);
346
+ }
347
+
348
+ @keyframes spin { to { transform: rotate(360deg); } }
349
+
350
+ .subtask-list { list-style: none; padding-left: 0; margin: 8px 0 0; font-size: 14px; color: var(--muted); }
351
+ .subtask-list li { margin: 4px 0; opacity: .8; transition: opacity .2s ease, color .2s ease, text-decoration-color .2s ease; }
352
+ .subtask-list li[data-status="in-progress"] { opacity: 1; color: #cbd5e1; }
353
+ .subtask-list li[data-status="success"] { opacity: 1; color: #93a3b3; text-decoration: line-through; }
354
+ .subtask-list li[data-status="error"] { opacity: 1; color: var(--danger); font-weight: 600; }
355
+
356
+ /* Terminal */
357
+ .terminal {
358
+ background: #0b1020;
359
+ border: 1px solid var(--border);
360
+ border-radius: 12px;
361
+ overflow: hidden;
362
+ box-shadow: inset 0 0 0 1px rgba(255,255,255,0.03);
363
+ }
364
+ .terminal-head {
365
+ display: flex; align-items: center; gap: 8px;
366
+ padding: 8px 10px; background: rgba(255,255,255,0.03);
367
+ border-bottom: 1px solid var(--border);
368
+ }
369
+ .terminal-head h4 { margin: 0 0 0 auto; color: var(--muted); font-weight: 600; }
370
+ .terminal .dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
371
+ .terminal .dot.red { background: #ef4444; }
372
+ .terminal .dot.yellow { background: #f59e0b; }
373
+ .terminal .dot.green { background: #22c55e; }
374
+
375
+ .terminal-body {
376
+ margin: 0; padding: 14px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
377
+ font-size: 13px; color: #cbd5e1; white-space: pre-wrap; word-wrap: break-word;
378
+ max-height: 420px; overflow: auto; line-height: 1.45;
379
+ background-image:
380
+ linear-gradient(rgba(255,255,255,0.025) 50%, transparent 50%);
381
+ background-size: 100% 22px;
382
+ }
383
+
384
+ /* Result */
385
+ .result-card {
386
+ background: linear-gradient(180deg, rgba(16,185,129,0.1), rgba(16,185,129,0.06));
387
+ border: 1px solid rgba(16,185,129,0.3);
388
+ border-radius: 12px;
389
+ padding: 14px;
390
+ }
391
+ .button-link {
392
+ display: inline-flex; align-items: center; gap: 8px;
393
+ text-decoration: none; color: white;
394
+ margin-top: 10px;
395
+ background: linear-gradient(180deg, var(--primary), var(--primary-2));
396
+ padding: 10px 14px; border-radius: 10px; font-weight: 700;
397
+ box-shadow: 0 8px 24px rgba(6, 182, 212, 0.25);
398
+ }
399
+
400
+ /* Utility */
401
+ .hidden { display: none !important; }
402
+
403
+ /* Accessibility: reduced motion */
404
+ @media (prefers-reduced-motion: reduce) {
405
+ * { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; scroll-behavior: auto !important; }
406
+ }