docuflow 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
docuflow/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """
2
+ DocuFlow: AI-Native Documentation & Architecture Maintenance Agent
3
+ """
4
+
5
+ __version__ = "0.3.0"
docuflow/ai_engine.py ADDED
@@ -0,0 +1,179 @@
1
+ import os
2
+ from pathlib import Path
3
+ from typing import List, Optional, Tuple
4
+ from google import generativeai as genai
5
+ from openai import OpenAI
6
+
7
+ from docuflow.config import DocuFlowConfig
8
+ from docuflow.context_builder import ImpactAnalysis
9
+
10
+ def find_associated_docs(filepath: str, docs_dir: Path) -> List[Path]:
11
+ """
12
+ Scans the documentation directory and matches markdown files that refer
13
+ to the given code filepath, filename, or module parent.
14
+ Normalizes casing, underscores, and hyphens to maximize match accuracy.
15
+ """
16
+ associated: List[Path] = []
17
+ if not docs_dir.exists():
18
+ return associated
19
+
20
+ filename = Path(filepath).name
21
+ basename = Path(filepath).stem
22
+
23
+ # Pre-calculate normalized flat values for code file
24
+ flat_basename = basename.replace("_", "").replace("-", "").lower()
25
+ flat_filename = filename.replace("_", "").replace("-", "").lower()
26
+
27
+ for md_file in docs_dir.glob("**/*.md"):
28
+ # Skip hidden or temporary files
29
+ if md_file.name.startswith("."):
30
+ continue
31
+ try:
32
+ content = md_file.read_text(encoding="utf-8")
33
+ flat_content = content.replace("_", "").replace("-", "").lower()
34
+ flat_md_filename = md_file.name.replace("_", "").replace("-", "").lower()
35
+
36
+ # Match if:
37
+ # - Flat filename stem (e.g., 'gitutils') is in the flat markdown content
38
+ # - Flat filename (e.g., 'gitutils.py') is in the flat markdown content
39
+ # - Flat filename stem (e.g., 'gitutils') matches the flat markdown filename stem
40
+ if (flat_filename in flat_content or
41
+ flat_basename in flat_content or
42
+ flat_basename in flat_md_filename):
43
+ associated.append(md_file)
44
+ except Exception:
45
+ pass
46
+
47
+ return associated
48
+
49
+ def format_ast_summary(analysis: ImpactAnalysis) -> str:
50
+ """
51
+ Formats a clean, human-readable summary of the AST modifications for the prompt.
52
+ """
53
+ summary = []
54
+ if analysis.added_entities:
55
+ summary.append("Added Code Entities:")
56
+ for ent in analysis.added_entities:
57
+ summary.append(f" - {ent.type.capitalize()} `{ent.name}` with signature: `{ent.signature}`")
58
+ if analysis.modified_entities:
59
+ summary.append("Modified Code Entities:")
60
+ for ent in analysis.modified_entities:
61
+ summary.append(f" - {ent.type.capitalize()} `{ent.name}` with signature: `{ent.signature}`")
62
+ if analysis.removed_entities:
63
+ summary.append("Removed/Deleted Code Entities:")
64
+ for ent in analysis.removed_entities:
65
+ summary.append(f" - {ent.type.capitalize()} `{ent.name}`")
66
+
67
+ return "\n".join(summary) if summary else "No high-level AST structural changes."
68
+
69
+ def build_orchestrator_prompt(
70
+ rules_content: str,
71
+ md_content: str,
72
+ md_filename: str,
73
+ analysis: ImpactAnalysis
74
+ ) -> str:
75
+ """
76
+ Assembles the detailed prompt for the AI documentation agent, passing the style rules,
77
+ current markdown file content, git diff, and AST modifications.
78
+ """
79
+ ast_summary = format_ast_summary(analysis)
80
+
81
+ prompt = f"""You are the DocuFlow AI Documentation Agent. Your job is to update the technical documentation markdown file to accurately reflect recent code modifications.
82
+
83
+ --- SYSTEM STYLING & FORMATTING RULES (documentation-rules.md) ---
84
+ {rules_content}
85
+
86
+ --- TARGET TECHNICAL DOCUMENT TO UPDATE ---
87
+ File Name: {md_filename}
88
+ Content:
89
+ ```markdown
90
+ {md_content}
91
+ ```
92
+
93
+ --- RAW CODE DIFF MODIFICATIONS ---
94
+ File: {analysis.filepath}
95
+ Diff:
96
+ ```diff
97
+ {analysis.raw_diff}
98
+ ```
99
+
100
+ --- EXTRACTED CODE AST CHANGES ---
101
+ {ast_summary}
102
+
103
+ --- MANDATORY INSTRUCTIONS ---
104
+ 1. Analyze the raw code changes and the high-level AST modifications.
105
+ 2. Update the target documentation file so it perfectly matches the new code structure (e.g., class names, function parameters, return types, or architectural flows).
106
+ 3. Perform a NON-DESTRUCTIVE update: only modify, add, or delete details that directly correspond to the code changes. Do NOT touch, rewrite, or delete surrounding unrelated text, descriptions, or headers.
107
+ 4. Synchronize or update any visual Mermaid diagrams inside the documentation to match the new code relationships or state flows, adhering strictly to the Mermaid standards (e.g., wrap node labels containing special characters in double quotes).
108
+ 5. Keep formatting intact. Return ONLY the complete, updated markdown content. Do not include any introductory remarks, conversational preambles, or markdown fences wrap outside the file itself.
109
+ """
110
+ return prompt
111
+
112
+ def execute_llm_update(
113
+ config: DocuFlowConfig,
114
+ prompt: str
115
+ ) -> Tuple[Optional[str], str]:
116
+ """
117
+ Executes the LLM request using the active configuration provider (Gemini or OpenAI).
118
+ Returns a tuple of (updated_markdown_content, error_message).
119
+ """
120
+ provider = config.ai.provider.lower()
121
+ model_name = config.ai.model
122
+ temp = config.ai.temperature
123
+ max_t = config.ai.max_tokens
124
+
125
+ if provider == "gemini":
126
+ api_key = os.environ.get("GEMINI_API_KEY")
127
+ if not api_key:
128
+ return None, "GEMINI_API_KEY environment variable is not set."
129
+ try:
130
+ genai.configure(api_key=api_key)
131
+ model = genai.GenerativeModel(
132
+ model_name=model_name,
133
+ generation_config={
134
+ "temperature": temp,
135
+ "max_output_tokens": max_t
136
+ }
137
+ )
138
+ response = model.generate_content(prompt)
139
+ content = response.text.strip()
140
+
141
+ # Strip outer markdown fences if returned
142
+ if content.startswith("```markdown"):
143
+ content = content[11:]
144
+ if content.endswith("```"):
145
+ content = content[:-3]
146
+ elif content.startswith("```") and content.endswith("```"):
147
+ content = content[3:-3]
148
+
149
+ return content.strip(), ""
150
+ except Exception as e:
151
+ return None, f"Gemini API call failed: {e}"
152
+
153
+ elif provider == "openai":
154
+ api_key = os.environ.get("OPENAI_API_KEY")
155
+ if not api_key:
156
+ return None, "OPENAI_API_KEY environment variable is not set."
157
+ try:
158
+ client = OpenAI(api_key=api_key)
159
+ response = client.chat.completions.create(
160
+ model=model_name,
161
+ messages=[{"role": "user", "content": prompt}],
162
+ temperature=temp,
163
+ max_tokens=max_t
164
+ )
165
+ content = response.choices[0].message.content.strip()
166
+
167
+ # Strip outer markdown fences if returned
168
+ if content.startswith("```markdown"):
169
+ content = content[11:]
170
+ if content.endswith("```"):
171
+ content = content[:-3]
172
+ elif content.startswith("```") and content.endswith("```"):
173
+ content = content[3:-3]
174
+
175
+ return content.strip(), ""
176
+ except Exception as e:
177
+ return None, f"OpenAI API call failed: {e}"
178
+
179
+ return None, f"Unsupported AI provider: {provider}"
docuflow/config.py ADDED
@@ -0,0 +1,57 @@
1
+ import os
2
+ from pathlib import Path
3
+ from typing import List, Optional
4
+ from pydantic import BaseModel, Field
5
+ import toml
6
+
7
+ class ProjectConfig(BaseModel):
8
+ name: str = "DocuFlow"
9
+ watch_dirs: List[str] = Field(default_factory=lambda: ["src"])
10
+
11
+ class DocumentationConfig(BaseModel):
12
+ docs_dir: str = "docs"
13
+ patterns: List[str] = Field(default_factory=lambda: ["*.md"])
14
+ rules_dir: str = ".agents/rules"
15
+ workflows_dir: str = ".agents/workflows"
16
+
17
+ class AIConfig(BaseModel):
18
+ provider: str = "gemini"
19
+ model: str = "gemini-1.5-pro"
20
+ temperature: float = 0.2
21
+ max_tokens: int = 4096
22
+
23
+ class GitConfig(BaseModel):
24
+ target_branch: str = "main"
25
+ include_unstaged: bool = True
26
+ include_staged: bool = True
27
+
28
+ class DocuFlowConfig(BaseModel):
29
+ project: ProjectConfig = Field(default_factory=ProjectConfig)
30
+ documentation: DocumentationConfig = Field(default_factory=DocumentationConfig)
31
+ ai: AIConfig = Field(default_factory=AIConfig)
32
+ git: GitConfig = Field(default_factory=GitConfig)
33
+
34
+ def load_config(config_path: Optional[Path] = None) -> DocuFlowConfig:
35
+ """
36
+ Loads and parses the docuflow.toml configuration file.
37
+ If no path is provided, checks the current working directory and its parents.
38
+ """
39
+ if config_path is None:
40
+ # Search upward from the current working directory for docuflow.toml
41
+ current_dir = Path.cwd()
42
+ for parent in [current_dir] + list(current_dir.parents):
43
+ candidate = parent / "docuflow.toml"
44
+ if candidate.is_file():
45
+ config_path = candidate
46
+ break
47
+
48
+ if config_path and config_path.is_file():
49
+ try:
50
+ with open(config_path, "r", encoding="utf-8") as f:
51
+ data = toml.load(f)
52
+ return DocuFlowConfig(**data)
53
+ except Exception:
54
+ # Fallback to default config on parse error
55
+ pass
56
+
57
+ return DocuFlowConfig()
@@ -0,0 +1,83 @@
1
+ from pathlib import Path
2
+ from typing import Dict, List, Optional
3
+ from pydantic import BaseModel, Field
4
+
5
+ from docuflow.parser import EntityInfo, parse_code_structure
6
+ from docuflow.git_utils import run_git_command, is_git_repo
7
+
8
+ class ImpactAnalysis(BaseModel):
9
+ """
10
+ Represents the full structural impact analysis of changes made to a file.
11
+ """
12
+ filepath: str
13
+ added_entities: List[EntityInfo] = Field(default_factory=list)
14
+ modified_entities: List[EntityInfo] = Field(default_factory=list)
15
+ removed_entities: List[EntityInfo] = Field(default_factory=list)
16
+ raw_diff: str = ""
17
+
18
+ def get_git_file_content(filepath: str, ref: str = "HEAD", cwd: Optional[Path] = None) -> str:
19
+ """
20
+ Retrieves the content of a file from Git history at a specific reference.
21
+ Returns an empty string if the file did not exist yet (e.g. newly added).
22
+ """
23
+ try:
24
+ return run_git_command(["show", f"{ref}:{filepath}"], cwd=cwd)
25
+ except Exception:
26
+ return ""
27
+
28
+ def build_impact_analysis(filepath: str, raw_diff: str, base_ref: str = "HEAD", cwd: Optional[Path] = None) -> ImpactAnalysis:
29
+ """
30
+ Compares the AST structures of a file between its Git base state and current filesystem state
31
+ to identify added, modified, or removed classes, functions, and methods.
32
+ """
33
+ # 1. Fetch original content from Git
34
+ original_content = get_git_file_content(filepath, ref=base_ref, cwd=cwd)
35
+
36
+ # 2. Fetch current content from local disk
37
+ current_path = (cwd or Path.cwd()) / filepath
38
+ current_content = ""
39
+ if current_path.is_file():
40
+ try:
41
+ current_content = current_path.read_text(encoding="utf-8")
42
+ except Exception:
43
+ pass
44
+
45
+ # 3. For Python files, parse and compare ASTs
46
+ if filepath.endswith(".py"):
47
+ old_entities = {e.name: e for e in parse_code_structure(original_content)}
48
+ new_entities = {e.name: e for e in parse_code_structure(current_content)}
49
+
50
+ added_entities = []
51
+ modified_entities = []
52
+ removed_entities = []
53
+
54
+ # Check added and modified entities
55
+ for name, new_ent in new_entities.items():
56
+ if name not in old_entities:
57
+ added_entities.append(new_ent)
58
+ else:
59
+ old_ent = old_entities[name]
60
+ # Consider it modified if signature or docstring changes, or if the size/bounds of implementation changed
61
+ if (new_ent.signature != old_ent.signature or
62
+ new_ent.docstring != old_ent.docstring or
63
+ (new_ent.line_end - new_ent.line_start) != (old_ent.line_end - old_ent.line_start)):
64
+ modified_entities.append(new_ent)
65
+
66
+ # Check removed entities
67
+ for name, old_ent in old_entities.items():
68
+ if name not in new_entities:
69
+ removed_entities.append(old_ent)
70
+
71
+ return ImpactAnalysis(
72
+ filepath=filepath,
73
+ added_entities=added_entities,
74
+ modified_entities=modified_entities,
75
+ removed_entities=removed_entities,
76
+ raw_diff=raw_diff
77
+ )
78
+
79
+ # Non-python files just map the raw diff without AST structures
80
+ return ImpactAnalysis(
81
+ filepath=filepath,
82
+ raw_diff=raw_diff
83
+ )
docuflow/git_utils.py ADDED
@@ -0,0 +1,188 @@
1
+ import subprocess
2
+ from pathlib import Path
3
+ from typing import Dict, List, Optional
4
+ from pydantic import BaseModel
5
+
6
+ class FileChange(BaseModel):
7
+ """
8
+ Represents a single file change extracted from Git.
9
+ """
10
+ filepath: str
11
+ change_type: str # 'A' (Added), 'M' (Modified), 'D' (Deleted), 'R' (Renamed), etc.
12
+ diff: str
13
+ module: str
14
+
15
+ def run_git_command(args: List[str], cwd: Optional[Path] = None) -> str:
16
+ """
17
+ Executes a git command and returns the stdout string.
18
+ Raises RuntimeError if the command fails.
19
+ """
20
+ try:
21
+ result = subprocess.run(
22
+ ["git"] + args,
23
+ capture_output=True,
24
+ text=True,
25
+ check=True,
26
+ cwd=cwd or Path.cwd()
27
+ )
28
+ return result.stdout.strip()
29
+ except subprocess.CalledProcessError as e:
30
+ raise RuntimeError(f"Git command failed: {' '.join(e.cmd)}\nError: {e.stderr.strip()}") from e
31
+ except FileNotFoundError as e:
32
+ raise RuntimeError("Git executable not found on system path.") from e
33
+
34
+ def is_git_repo(cwd: Optional[Path] = None) -> bool:
35
+ """
36
+ Checks if the given directory is inside a git repository.
37
+ """
38
+ try:
39
+ output = run_git_command(["rev-parse", "--is-inside-work-tree"], cwd=cwd)
40
+ return output == "true"
41
+ except RuntimeError:
42
+ return False
43
+
44
+ def get_git_root(cwd: Optional[Path] = None) -> Path:
45
+ """
46
+ Gets the absolute Path of the Git repository root.
47
+ """
48
+ output = run_git_command(["rev-parse", "--show-toplevel"], cwd=cwd)
49
+ return Path(output).resolve()
50
+
51
+ def extract_module(filepath: str) -> str:
52
+ """
53
+ Helper to extract the module/folder name for grouping.
54
+ e.g., 'src/auth/login.py' -> 'src/auth'
55
+ 'src/main.py' -> 'src'
56
+ 'plan.md' -> '.'
57
+ """
58
+ path = Path(filepath)
59
+ parts = path.parts
60
+ if len(parts) > 2:
61
+ return str(Path(*parts[:2]))
62
+ elif len(parts) == 2:
63
+ return parts[0]
64
+ else:
65
+ return "."
66
+
67
+ def parse_name_status_line(line: str) -> Optional[tuple[str, str]]:
68
+ """
69
+ Parses a line from `git diff --name-status`
70
+ e.g., 'M\tsrc/main.py' -> ('M', 'src/main.py')
71
+ """
72
+ if not line.strip():
73
+ return None
74
+ parts = line.split("\t")
75
+ if len(parts) >= 2:
76
+ # handle renamed status which could be 'R100\told_name\tnew_name'
77
+ status = parts[0][0] # Just take the first character (e.g. 'R', 'M', 'A')
78
+ filepath = parts[-1] # Take the final destination file path
79
+ return status, filepath
80
+ return None
81
+
82
+ def get_file_diff(filepath: str, extra_args: List[str], cwd: Optional[Path] = None) -> str:
83
+ """
84
+ Gets the diff content for a specific file.
85
+ """
86
+ try:
87
+ # run git diff with specific arguments and targeting the file
88
+ return run_git_command(["diff"] + extra_args + ["--", filepath], cwd=cwd)
89
+ except RuntimeError:
90
+ return ""
91
+
92
+ def get_unstaged_changes(cwd: Optional[Path] = None) -> List[FileChange]:
93
+ """
94
+ Retrieves all unstaged file modifications and their diffs.
95
+ """
96
+ if not is_git_repo(cwd):
97
+ return []
98
+
99
+ # Get the status list of unstaged files
100
+ status_output = run_git_command(["diff", "--name-status"], cwd=cwd)
101
+ changes = []
102
+
103
+ for line in status_output.splitlines():
104
+ parsed = parse_name_status_line(line)
105
+ if not parsed:
106
+ continue
107
+ status, filepath = parsed
108
+ # Get diff for this specific unstaged file
109
+ diff = get_file_diff(filepath, [], cwd=cwd)
110
+ changes.append(FileChange(
111
+ filepath=filepath,
112
+ change_type=status,
113
+ diff=diff,
114
+ module=extract_module(filepath)
115
+ ))
116
+
117
+ return changes
118
+
119
+ def get_staged_changes(cwd: Optional[Path] = None) -> List[FileChange]:
120
+ """
121
+ Retrieves all staged file modifications and their diffs.
122
+ """
123
+ if not is_git_repo(cwd):
124
+ return []
125
+
126
+ # Get the status list of staged files
127
+ status_output = run_git_command(["diff", "--cached", "--name-status"], cwd=cwd)
128
+ changes = []
129
+
130
+ for line in status_output.splitlines():
131
+ parsed = parse_name_status_line(line)
132
+ if not parsed:
133
+ continue
134
+ status, filepath = parsed
135
+ # Get diff for this specific staged file
136
+ diff = get_file_diff(filepath, ["--cached"], cwd=cwd)
137
+ changes.append(FileChange(
138
+ filepath=filepath,
139
+ change_type=status,
140
+ diff=diff,
141
+ module=extract_module(filepath)
142
+ ))
143
+
144
+ return changes
145
+
146
+ def get_branch_diff(target_branch: str, cwd: Optional[Path] = None) -> List[FileChange]:
147
+ """
148
+ Retrieves file modifications and diffs between current branch (HEAD) and a target branch/commit.
149
+ Uses target_branch...HEAD (triple dot) to see changes introduced on current branch since it split from target.
150
+ """
151
+ if not is_git_repo(cwd):
152
+ return []
153
+
154
+ try:
155
+ # Check if the target branch exists or can be resolved
156
+ run_git_command(["rev-parse", "--verify", target_branch], cwd=cwd)
157
+ except RuntimeError:
158
+ # Fallback to single-dot or direct branch comparison if the reference is different
159
+ pass
160
+
161
+ # Get status list comparing the target branch to current HEAD
162
+ status_output = run_git_command(["diff", f"{target_branch}...HEAD", "--name-status"], cwd=cwd)
163
+ changes = []
164
+
165
+ for line in status_output.splitlines():
166
+ parsed = parse_name_status_line(line)
167
+ if not parsed:
168
+ continue
169
+ status, filepath = parsed
170
+ # Get diff comparison
171
+ diff = get_file_diff(filepath, [f"{target_branch}...HEAD"], cwd=cwd)
172
+ changes.append(FileChange(
173
+ filepath=filepath,
174
+ change_type=status,
175
+ diff=diff,
176
+ module=extract_module(filepath)
177
+ ))
178
+
179
+ return changes
180
+
181
+ def group_changes_by_module(changes: List[FileChange]) -> Dict[str, List[FileChange]]:
182
+ """
183
+ Groups a list of FileChange objects by their module folder.
184
+ """
185
+ grouped: Dict[str, List[FileChange]] = {}
186
+ for change in changes:
187
+ grouped.setdefault(change.module, []).append(change)
188
+ return grouped
docuflow/main.py ADDED
@@ -0,0 +1,534 @@
1
+ import sys
2
+ from pathlib import Path
3
+ from typing import Dict, List, Optional
4
+ import typer
5
+ from rich.console import Console
6
+ from rich.panel import Panel
7
+ from rich.table import Table
8
+ from rich.status import Status
9
+ from rich.markdown import Markdown
10
+ from rich import print as rprint
11
+
12
+ from docuflow.config import load_config, DocuFlowConfig
13
+ from docuflow.git_utils import (
14
+ is_git_repo,
15
+ get_unstaged_changes,
16
+ get_staged_changes,
17
+ get_branch_diff,
18
+ group_changes_by_module,
19
+ FileChange,
20
+ )
21
+ from docuflow.context_builder import build_impact_analysis
22
+ from docuflow.ai_engine import (
23
+ find_associated_docs,
24
+ execute_llm_update,
25
+ build_orchestrator_prompt,
26
+ )
27
+
28
+ app = typer.Typer(
29
+ name="docuflow",
30
+ help="🤖 AI-Native Documentation & Architecture Maintenance Agent",
31
+ no_args_is_help=True,
32
+ )
33
+ console = Console()
34
+
35
+ @app.command("init")
36
+ def init_cmd(
37
+ config_path: Path = typer.Option(
38
+ Path("docuflow.toml"),
39
+ "--config",
40
+ "-c",
41
+ help="Path where the configuration file should be created."
42
+ )
43
+ ):
44
+ """
45
+ Initialize a new DocuFlow configuration and workspace structure.
46
+ """
47
+ console.print("[bold blue]⚡ Initializing DocuFlow Project...[/bold blue]\n")
48
+
49
+ # 1. Create docuflow.toml if not exists
50
+ if config_path.exists():
51
+ console.print(f"[yellow]⚠️ Configuration file '{config_path}' already exists. Skipping creation.[/yellow]")
52
+ else:
53
+ # Create a basic default template config
54
+ config_content = """# DocuFlow Configuration Template
55
+ # This file controls targeting, rules, and LLM providers for the DocuFlow agent.
56
+
57
+ [project]
58
+ name = "DocuFlow"
59
+ watch_dirs = ["src"]
60
+
61
+ [documentation]
62
+ docs_dir = "docs"
63
+ patterns = ["*.md"]
64
+ rules_dir = ".agents/rules"
65
+ workflows_dir = ".agents/workflows"
66
+
67
+ [ai]
68
+ provider = "gemini"
69
+ model = "gemini-1.5-pro"
70
+ temperature = 0.2
71
+ max_tokens = 4096
72
+
73
+ [git]
74
+ target_branch = "main"
75
+ include_unstaged = true
76
+ include_staged = true
77
+ """
78
+ try:
79
+ config_path.write_text(config_content, encoding="utf-8")
80
+ console.print(f"[green]✅ Created configuration template: [bold]{config_path}[/bold][/green]")
81
+ except Exception as e:
82
+ console.print(f"[red]❌ Failed to create config file: {e}[/red]")
83
+ raise typer.Exit(code=1)
84
+
85
+ # 2. Create docs directory if it doesn't exist
86
+ config = load_config(config_path)
87
+ docs_dir = Path(config.documentation.docs_dir)
88
+ if not docs_dir.exists():
89
+ docs_dir.mkdir(parents=True, exist_ok=True)
90
+ console.print(f"[green]✅ Created documentation directory: [bold]{docs_dir}/[/bold][/green]")
91
+ else:
92
+ console.print(f"[yellow]ℹ️ Documentation directory '{docs_dir}/' already exists.[/yellow]")
93
+
94
+ # 3. Create .agents rules/workflows subdirectories if needed
95
+ rules_dir = Path(config.documentation.rules_dir)
96
+ rules_dir.mkdir(parents=True, exist_ok=True)
97
+
98
+ workflows_dir = Path(config.documentation.workflows_dir)
99
+ workflows_dir.mkdir(parents=True, exist_ok=True)
100
+
101
+ console.print("\n[bold green]🎉 DocuFlow project successfully initialized! Ready to manage technical documentation.[/bold green]")
102
+
103
+ @app.command("run")
104
+ def run_cmd(
105
+ config_path: Optional[Path] = typer.Option(
106
+ None,
107
+ "--config",
108
+ "-c",
109
+ help="Path to the docuflow.toml configuration file."
110
+ ),
111
+ target_branch: Optional[str] = typer.Option(
112
+ None,
113
+ "--branch",
114
+ "-b",
115
+ help="Target branch/ref to compare against (e.g., origin/main)."
116
+ ),
117
+ staged: Optional[bool] = typer.Option(
118
+ None,
119
+ "--staged/--no-staged",
120
+ help="Force include/exclude staged changes in the analysis."
121
+ ),
122
+ unstaged: Optional[bool] = typer.Option(
123
+ None,
124
+ "--unstaged/--no-unstaged",
125
+ help="Force include/exclude unstaged changes in the analysis."
126
+ ),
127
+ ):
128
+ """
129
+ Analyze repository changes, group by module, and show what DocuFlow will process.
130
+ """
131
+ console.print("[bold blue]🔍 DocuFlow Git Analysis Engine[/bold blue]\n")
132
+
133
+ config = load_config(config_path)
134
+
135
+ # Resolve overrides vs config file defaults
136
+ inc_staged = staged if staged is not None else config.git.include_staged
137
+ inc_unstaged = unstaged if unstaged is not None else config.git.include_unstaged
138
+ branch = target_branch or config.git.target_branch
139
+
140
+ if not is_git_repo():
141
+ console.print("[bold red]❌ Error: Current directory is not a Git repository.[/bold red]")
142
+ raise typer.Exit(code=1)
143
+
144
+ all_changes: List[FileChange] = []
145
+
146
+ with console.status("[bold green]Analyzing workspace changes...") as status:
147
+ # Get staged changes
148
+ if inc_staged:
149
+ try:
150
+ staged_changes = get_staged_changes()
151
+ # Label change_type inside representation for display if needed
152
+ all_changes.extend(staged_changes)
153
+ except Exception as e:
154
+ console.print(f"[yellow]⚠️ Could not fetch staged changes: {e}[/yellow]")
155
+
156
+ # Get unstaged changes
157
+ if inc_unstaged:
158
+ try:
159
+ unstaged_changes = get_unstaged_changes()
160
+ all_changes.extend(unstaged_changes)
161
+ except Exception as e:
162
+ console.print(f"[yellow]⚠️ Could not fetch unstaged changes: {e}[/yellow]")
163
+
164
+ # Get branch difference if specified or fallback
165
+ if branch and not all_changes:
166
+ try:
167
+ branch_changes = get_branch_diff(branch)
168
+ all_changes.extend(branch_changes)
169
+ except Exception as e:
170
+ console.print(f"[yellow]⚠️ Could not fetch diff against '{branch}': {e}[/yellow]")
171
+
172
+ if not all_changes:
173
+ console.print("[bold green]✨ No file modifications detected! Documentation is up to date.[/bold green]")
174
+ return
175
+
176
+ # Group changes by module
177
+ grouped = group_changes_by_module(all_changes)
178
+
179
+ # Display the summary table
180
+ table = Table(title="Detected Code Changes grouped by Modules", title_style="bold magenta")
181
+ table.add_column("Module / Folder", style="cyan", no_wrap=True)
182
+ table.add_column("File Path", style="green")
183
+ table.add_column("Status", style="yellow", justify="center")
184
+ table.add_column("Lines of Diff", style="white", justify="right")
185
+
186
+ total_changes = 0
187
+ impact_summaries = []
188
+
189
+ for module, changes in grouped.items():
190
+ for change in changes:
191
+ diff_lines = len(change.diff.splitlines()) if change.diff else 0
192
+ # Friendly change type labels
193
+ status_map = {"A": "Added 🆕", "M": "Modified 📝", "D": "Deleted 🗑️", "R": "Renamed 🔄"}
194
+ status_label = status_map.get(change.change_type, change.change_type)
195
+
196
+ table.add_row(module, change.filepath, status_label, str(diff_lines))
197
+ total_changes += 1
198
+
199
+ # Extract AST impact on Python code modifications
200
+ if change.filepath.endswith(".py"):
201
+ try:
202
+ analysis = build_impact_analysis(change.filepath, change.diff)
203
+ if analysis.added_entities or analysis.modified_entities or analysis.removed_entities:
204
+ impact_summaries.append((change.filepath, analysis))
205
+ except Exception:
206
+ pass
207
+
208
+ console.print(table)
209
+ console.print(f"\n[bold green]📦 Total files changed: {total_changes} across {len(grouped)} modules.[/bold green]")
210
+
211
+ # Render Deep AST Structural impact report
212
+ if impact_summaries:
213
+ console.print("\n[bold magenta]🔬 Deep AST Code Impact Analysis[/bold magenta]")
214
+ for filepath, analysis in impact_summaries:
215
+ console.print(f" [bold cyan]• {filepath}[/bold cyan]")
216
+
217
+ if analysis.added_entities:
218
+ for ent in analysis.added_entities:
219
+ doc_flag = " 📝 [dim](has docstring)[/dim]" if ent.docstring else ""
220
+ console.print(f" [green]🆕 [Added] {ent.type} [bold]{ent.signature}[/bold][/green]{doc_flag}")
221
+
222
+ if analysis.modified_entities:
223
+ for ent in analysis.modified_entities:
224
+ doc_flag = " 📝 [dim](has docstring)[/dim]" if ent.docstring else ""
225
+ console.print(f" [yellow]📝 [Modified] {ent.type} [bold]{ent.signature}[/bold][/yellow]{doc_flag}")
226
+
227
+ if analysis.removed_entities:
228
+ for ent in analysis.removed_entities:
229
+ console.print(f" [red]🗑️ [Removed] {ent.type} [bold]{ent.name}[/bold][/red]")
230
+
231
+ # Showcase what Phase 3 will execute (LLM Execution)
232
+ console.print(
233
+ Panel(
234
+ "[bold white]🚀 Phase 1 & Phase 2 Complete![/bold white]\n"
235
+ "The repository diff has been parsed, and code entities have been extracted at the AST level.\n"
236
+ "In Phase 3, this structural impact context will trigger the AI documentation agent "
237
+ "to perform context-aware updates to relevant markdown files and sync visual Mermaid flowcharts.",
238
+ title="Next Steps (AI Documentation Engine)",
239
+ border_style="magenta",
240
+ )
241
+ )
242
+
243
+ def check_markdown_content(content: str) -> List[str]:
244
+ """
245
+ Runs a single-pass, stateful line parser to check markdown alignment with guidelines.
246
+ Returns a list of issues found.
247
+ """
248
+ issues = []
249
+ lines = content.splitlines()
250
+ in_code_block = False
251
+ in_mermaid = False
252
+ h1_count = 0
253
+ todos = []
254
+ unspecified_blocks = []
255
+ mermaid_issues = []
256
+
257
+ for i, line in enumerate(lines):
258
+ # Handle code block state toggle
259
+ if line.startswith("```"):
260
+ if not in_code_block:
261
+ specifier = line[3:].strip()
262
+ if not specifier:
263
+ unspecified_blocks.append(i + 1)
264
+ if specifier == "mermaid":
265
+ in_mermaid = True
266
+ in_code_block = True
267
+ else:
268
+ in_code_block = False
269
+ in_mermaid = False
270
+ continue
271
+
272
+ if in_code_block:
273
+ if in_mermaid:
274
+ if "[" in line and "]" in line and '"' not in line and ("(" in line or ")" in line):
275
+ mermaid_issues.append(i + 1)
276
+ continue
277
+
278
+ # Outside code blocks: Check for guidelines
279
+ if line.startswith("# ") and not line.startswith("##"):
280
+ h1_count += 1
281
+
282
+ if "TODO" in line or "FIXME" in line:
283
+ todos.append(i + 1)
284
+
285
+ if h1_count == 0:
286
+ issues.append("Missing single standard H1 header (`# Title`)")
287
+ elif h1_count > 1:
288
+ issues.append(f"Multiple H1 headers found ({h1_count})")
289
+
290
+ if todos:
291
+ issues.append(f"Contains TODO / placeholders on line(s): {', '.join(map(str, todos))}")
292
+
293
+ if unspecified_blocks:
294
+ issues.append(f"Code block missing language specifier on line(s): {', '.join(map(str, unspecified_blocks))}")
295
+
296
+ if mermaid_issues:
297
+ issues.append(f"Mermaid label with special characters missing quotes on line(s): {', '.join(map(str, mermaid_issues))}")
298
+
299
+ return issues
300
+
301
+ @app.command("check")
302
+ def check_cmd(
303
+ config_path: Optional[Path] = typer.Option(
304
+ None,
305
+ "--config",
306
+ "-c",
307
+ help="Path to the docuflow.toml configuration file."
308
+ ),
309
+ docs_dir: Optional[Path] = typer.Option(
310
+ None,
311
+ "--docs-dir",
312
+ "-d",
313
+ help="Override path to the documentation directory."
314
+ )
315
+ ):
316
+ """
317
+ Perform a health check on technical documentation files to ensure alignment with rules.
318
+ """
319
+ console.print("[bold blue]🩺 DocuFlow Documentation Health Checker[/bold blue]\n")
320
+
321
+ config = load_config(config_path)
322
+ target_docs = docs_dir or Path(config.documentation.docs_dir)
323
+
324
+ if not target_docs.exists():
325
+ console.print(f"[bold red]❌ Error: Documentation directory '{target_docs}' does not exist.[/bold red]")
326
+ console.print("[yellow]💡 Run [bold]docuflow init[/bold] to set up the default structure.[/yellow]")
327
+ raise typer.Exit(code=1)
328
+
329
+ md_files = list(target_docs.glob("**/*.md"))
330
+ if not md_files:
331
+ console.print(f"[yellow]⚠️ No markdown (.md) files found in documentation directory '{target_docs}'.[/yellow]")
332
+ return
333
+
334
+ console.print(f"Checking {len(md_files)} markdown files in '[bold]{target_docs}[/bold]'...\n")
335
+
336
+ table = Table(title="Documentation Health Check Report", title_style="bold magenta")
337
+ table.add_column("Markdown File", style="cyan")
338
+ table.add_column("Status", style="bold", justify="center")
339
+ table.add_column("Details / Recommendations", style="white")
340
+
341
+ passed_count = 0
342
+ failed_count = 0
343
+
344
+ for md_file in md_files:
345
+ issues = []
346
+ try:
347
+ content = md_file.read_text(encoding="utf-8")
348
+ issues = check_markdown_content(content)
349
+ except Exception as e:
350
+ issues.append(f"Failed to read file: {e}")
351
+
352
+ # Determine file path relative to workspace root
353
+ try:
354
+ rel_path = md_file.relative_to(Path.cwd())
355
+ except ValueError:
356
+ rel_path = md_file
357
+
358
+ if not issues:
359
+ table.add_row(str(rel_path), "[bold green]PASS ✅[/bold green]", "Perfect! Alignment with all formatting rules.")
360
+ passed_count += 1
361
+ else:
362
+ issues_joined = "; ".join(issues)
363
+ table.add_row(str(rel_path), "[bold red]FAIL ❌[/bold red]", f"[yellow]{issues_joined}[/yellow]")
364
+ failed_count += 1
365
+
366
+ console.print(table)
367
+
368
+ console.print(f"\n[bold]Summary:[/bold] [green]{passed_count} Passed[/green], [red]{failed_count} Failed[/red].")
369
+
370
+ if failed_count > 0:
371
+ console.print("\n[bold yellow]💡 Recommendation:[/bold yellow] Clean up the failed files above to comply with [bold]documentation-rules.md[/bold].")
372
+
373
+ @app.command("sync")
374
+ def sync_cmd(
375
+ config_path: Optional[Path] = typer.Option(
376
+ None,
377
+ "--config",
378
+ "-c",
379
+ help="Path to the docuflow.toml configuration file."
380
+ ),
381
+ target_branch: Optional[str] = typer.Option(
382
+ None,
383
+ "--branch",
384
+ "-b",
385
+ help="Target branch/ref to compare against (e.g., origin/main)."
386
+ ),
387
+ dry_run: bool = typer.Option(
388
+ False,
389
+ "--dry-run",
390
+ "-d",
391
+ help="Run in dry-run mode. Generates and displays prompts without calling LLM or writing files."
392
+ )
393
+ ):
394
+ """
395
+ Automatically synchronize technical markdown documentation with recent code updates using AI.
396
+ """
397
+ console.print("[bold blue]🤖 DocuFlow AI Documentation Orchestration[/bold blue]\n")
398
+
399
+ config = load_config(config_path)
400
+ branch = target_branch or config.git.target_branch
401
+
402
+ if not is_git_repo():
403
+ console.print("[bold red]❌ Error: Current directory is not a Git repository.[/bold red]")
404
+ raise typer.Exit(code=1)
405
+
406
+ # 1. Fetch changed files
407
+ all_changes: List[FileChange] = []
408
+ try:
409
+ if config.git.include_staged:
410
+ all_changes.extend(get_staged_changes())
411
+ if config.git.include_unstaged:
412
+ all_changes.extend(get_unstaged_changes())
413
+ if branch and not all_changes:
414
+ all_changes.extend(get_branch_diff(branch))
415
+ except Exception as e:
416
+ console.print(f"[bold red]❌ Error fetching changes: {e}[/bold red]")
417
+ raise typer.Exit(code=1)
418
+
419
+ if not all_changes:
420
+ console.print("[bold green]✨ No code modifications detected! Documentation is up to date.[/bold green]")
421
+ return
422
+
423
+ # 2. Locate guidelines rules file
424
+ rules_file = Path(".agents/rules/documentation-rules.md")
425
+ rules_content = ""
426
+ if rules_file.exists():
427
+ try:
428
+ rules_content = rules_file.read_text(encoding="utf-8")
429
+ except Exception:
430
+ pass
431
+ if not rules_content:
432
+ rules_content = "# Guidelines\n* Use single standard H1 title.\n* Wrap Mermaid special labels in quotes.\n* Always fence code blocks with languages."
433
+
434
+ docs_dir = Path(config.documentation.docs_dir)
435
+ synced_any = False
436
+
437
+ for change in all_changes:
438
+ # We only sync context for modified or added files
439
+ if change.change_type not in ["M", "A"]:
440
+ continue
441
+
442
+ # AST analysis
443
+ try:
444
+ analysis = build_impact_analysis(change.filepath, change.diff)
445
+ except Exception as e:
446
+ console.print(f"[yellow]⚠️ Skipped AST parsing for {change.filepath}: {e}[/yellow]")
447
+ continue
448
+
449
+ # Find associated markdown files
450
+ associated = find_associated_docs(change.filepath, docs_dir)
451
+ if not associated:
452
+ continue
453
+
454
+ for md_path in associated:
455
+ synced_any = True
456
+ console.print(f"[bold cyan]🔗 Found associated documentation: {md_path}[/bold cyan]")
457
+
458
+ try:
459
+ md_content = md_path.read_text(encoding="utf-8")
460
+ except Exception as e:
461
+ console.print(f"[red]❌ Failed to read {md_path}: {e}[/red]")
462
+ continue
463
+
464
+ # Build prompt
465
+ prompt = build_orchestrator_prompt(
466
+ rules_content=rules_content,
467
+ md_content=md_content,
468
+ md_filename=md_path.name,
469
+ analysis=analysis
470
+ )
471
+
472
+ if dry_run:
473
+ # Pretty print prompt
474
+ console.print(Panel(prompt, title=f"📋 Dry-Run AI Prompt for {md_path.name}", border_style="yellow"))
475
+ console.print(f"[bold yellow]⚠️ Dry-run: skipped API call for {md_path.name}[/bold yellow]\n")
476
+ continue
477
+
478
+ # Call AI
479
+ provider_label = config.ai.provider.upper()
480
+ with console.status(f"[bold green]Running AI Sync ({provider_label}) for {md_path.name}...") as status:
481
+ updated_content, err = execute_llm_update(config, prompt)
482
+
483
+ if err:
484
+ console.print(f"[bold red]❌ AI Sync Failed: {err}[/bold red]")
485
+ console.print(f"[yellow]💡 Tip: Set the environment variable {provider_label}_API_KEY or run with --dry-run[/yellow]\n")
486
+ continue
487
+
488
+ if not updated_content:
489
+ console.print(f"[bold red]❌ AI returned empty response for {md_path.name}[/bold red]\n")
490
+ continue
491
+
492
+ # Validate generated markdown before saving
493
+ issues = check_markdown_content(updated_content)
494
+ if issues:
495
+ console.print(f"[bold yellow]⚠️ Warning: AI output for {md_path.name} violated styling rules:[/bold yellow]")
496
+ for issue in issues:
497
+ console.print(f" - [yellow]{issue}[/yellow]")
498
+ console.print("[bold yellow]Proceeding to save with warnings...[/bold yellow]")
499
+
500
+ # Save the file
501
+ try:
502
+ md_path.write_text(updated_content, encoding="utf-8")
503
+ console.print(f"[bold green]✅ Successfully updated technical documentation: {md_path}[/bold green]\n")
504
+ except Exception as e:
505
+ console.print(f"[bold red]❌ Failed to save changes to {md_path}: {e}[/bold red]\n")
506
+
507
+ if not synced_any:
508
+ console.print("[bold yellow]⚠️ No associated documentation files were found in the docs directory for the changed files.[/bold yellow]")
509
+ console.print(f"[dim]Note: Documentation is matched if the file name stem or classes are mentioned in the markdown file.[/dim]")
510
+
511
+ @app.command("view")
512
+ def view_cmd(
513
+ filepath: Path = typer.Argument(
514
+ ...,
515
+ help="Path to the technical markdown (.md) document to view."
516
+ )
517
+ ):
518
+ """
519
+ Render a technical documentation markdown file directly inside the terminal with beautiful, rich formatting.
520
+ """
521
+ if not filepath.exists():
522
+ console.print(f"[bold red]❌ Error: File '{filepath}' does not exist.[/bold red]")
523
+ raise typer.Exit(code=1)
524
+
525
+ try:
526
+ content = filepath.read_text(encoding="utf-8")
527
+ md = Markdown(content)
528
+ console.print(md)
529
+ except Exception as e:
530
+ console.print(f"[bold red]❌ Error reading or rendering file: {e}[/bold red]")
531
+ raise typer.Exit(code=1)
532
+
533
+ if __name__ == "__main__":
534
+ app()
docuflow/parser.py ADDED
@@ -0,0 +1,105 @@
1
+ import ast
2
+ from typing import List, Optional
3
+ from pydantic import BaseModel, Field
4
+
5
+ class ParameterInfo(BaseModel):
6
+ """
7
+ Represents metadata for a function or method parameter.
8
+ """
9
+ name: str
10
+ type_annotation: Optional[str] = None
11
+
12
+ class EntityInfo(BaseModel):
13
+ """
14
+ Represents a structural code entity (class, function, or method).
15
+ """
16
+ name: str
17
+ type: str # "class", "function", "method"
18
+ signature: str
19
+ docstring: Optional[str] = None
20
+ line_start: int
21
+ line_end: int
22
+ parameters: List[ParameterInfo] = Field(default_factory=list)
23
+ return_type: Optional[str] = None
24
+
25
+ class PythonASTVisitor(ast.NodeVisitor):
26
+ """
27
+ AST Visitor to traverse and extract high-level structural classes and functions.
28
+ """
29
+ def __init__(self):
30
+ self.entities: List[EntityInfo] = []
31
+ self.current_class: Optional[str] = None
32
+
33
+ def visit_ClassDef(self, node: ast.ClassDef):
34
+ docstring = ast.get_docstring(node)
35
+ # Class bases / inheritance
36
+ bases = [ast.unparse(b) for b in node.bases]
37
+ bases_str = f"({', '.join(bases)})" if bases else ""
38
+ signature = f"class {node.name}{bases_str}"
39
+
40
+ self.entities.append(EntityInfo(
41
+ name=node.name,
42
+ type="class",
43
+ signature=signature,
44
+ docstring=docstring,
45
+ line_start=node.lineno,
46
+ line_end=getattr(node, "end_lineno", node.lineno),
47
+ ))
48
+
49
+ # Save context to visit methods inside this class
50
+ old_class = self.current_class
51
+ self.current_class = node.name
52
+ self.generic_visit(node)
53
+ self.current_class = old_class
54
+
55
+ def visit_FunctionDef(self, node: ast.FunctionDef):
56
+ self.visit_any_function(node)
57
+
58
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef):
59
+ self.visit_any_function(node)
60
+
61
+ def visit_any_function(self, node):
62
+ docstring = ast.get_docstring(node)
63
+
64
+ # Extract parameter details
65
+ params = []
66
+ for arg in node.args.args:
67
+ annotation = ast.unparse(arg.annotation) if arg.annotation else None
68
+ params.append(ParameterInfo(name=arg.arg, type_annotation=annotation))
69
+
70
+ return_type = ast.unparse(node.returns) if node.returns else None
71
+ prefix = "async def" if isinstance(node, ast.AsyncFunctionDef) else "def"
72
+ func_type = "method" if self.current_class else "function"
73
+
74
+ # Format human-readable signature
75
+ args_str = ", ".join([p.name + (f": {p.type_annotation}" if p.type_annotation else "") for p in params])
76
+ signature = f"{prefix} {node.name}({args_str})"
77
+ if return_type:
78
+ signature += f" -> {return_type}"
79
+
80
+ self.entities.append(EntityInfo(
81
+ name=f"{self.current_class}.{node.name}" if self.current_class else node.name,
82
+ type=func_type,
83
+ signature=signature,
84
+ docstring=docstring,
85
+ line_start=node.lineno,
86
+ line_end=getattr(node, "end_lineno", node.lineno),
87
+ parameters=params,
88
+ return_type=return_type
89
+ ))
90
+
91
+ # Keep walking to capture nested structures if any
92
+ self.generic_visit(node)
93
+
94
+ def parse_code_structure(code: str) -> List[EntityInfo]:
95
+ """
96
+ Parses a string of Python code and returns a list of high-level code entities.
97
+ Returns an empty list if compilation fails (e.g., SyntaxError).
98
+ """
99
+ try:
100
+ tree = ast.parse(code)
101
+ visitor = PythonASTVisitor()
102
+ visitor.visit(tree)
103
+ return visitor.entities
104
+ except (SyntaxError, ValueError, TypeError):
105
+ return []
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.4
2
+ Name: docuflow
3
+ Version: 0.3.0
4
+ Summary: AI-Native Documentation & Architecture Maintenance Agent
5
+ Author: DocuFlow Developer
6
+ License: MIT
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Operating System :: OS Independent
10
+ Requires-Python: >=3.9
11
+ Description-Content-Type: text/markdown
12
+ Requires-Dist: typer>=0.9.0
13
+ Requires-Dist: rich>=13.0.0
14
+ Requires-Dist: pydantic>=2.0.0
15
+ Requires-Dist: toml>=0.10.2
16
+ Requires-Dist: gitpython>=3.1.30
17
+ Requires-Dist: google-generativeai>=0.3.0
18
+ Requires-Dist: openai>=1.0.0
@@ -0,0 +1,12 @@
1
+ docuflow/__init__.py,sha256=2T9r9Wswt_-AoPkH5ZJMOYhEXjeRaAB8JtzVR9JB6Xg,98
2
+ docuflow/ai_engine.py,sha256=Q0JGiWu-8otW9Nb-59HJbJ4-zuRb6kgjokBx_JJVd_0,7215
3
+ docuflow/config.py,sha256=luJYZmd9OTwrv-V-mBpYPUni_tpt5DjqPLgOTmLNvb0,1948
4
+ docuflow/context_builder.py,sha256=T9NdO51I4meEIX91O6gmQ0silvVu7fe8AU2phsX9Has,3345
5
+ docuflow/git_utils.py,sha256=Cxo22N0x5uRCTIH_789mdZyPX8pc6MeY5UGL6qu7t3o,6189
6
+ docuflow/main.py,sha256=J-i64DiDFlZFD5K1ro1tztiUTJrs_DKiQCBmr3wGa6s,20395
7
+ docuflow/parser.py,sha256=Lf9EueeYsxitUFfPJknrHx9kWhRAkUM9oGBzdLVt62Y,3698
8
+ docuflow-0.3.0.dist-info/METADATA,sha256=Vs6QTtNqEv6bd5UgkhMN9tlEM-qUERB5Ep5ziR4naxU,586
9
+ docuflow-0.3.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
10
+ docuflow-0.3.0.dist-info/entry_points.txt,sha256=LmScDeRtkjbJwiQ8ig1TSqUUlOmHY_vGBEnYOPECeLo,47
11
+ docuflow-0.3.0.dist-info/top_level.txt,sha256=kvU6iukZnp5Vw_NOn_71vEzGc9lvEkpBp5DlJcRrOGA,9
12
+ docuflow-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ docuflow = docuflow.main:app
@@ -0,0 +1 @@
1
+ docuflow