husk-local 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Husk Authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,76 @@
1
+ Metadata-Version: 2.4
2
+ Name: husk-local
3
+ Version: 0.1.0
4
+ Summary: A local-first CLI engine for legacy codebases
5
+ Classifier: Programming Language :: Python :: 3
6
+ Classifier: License :: OSI Approved :: MIT License
7
+ Classifier: Operating System :: OS Independent
8
+ Requires-Python: >=3.9
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: click>=8.0.0
12
+ Requires-Dist: tree-sitter>=0.21.3
13
+ Requires-Dist: tree-sitter-python>=0.21.0
14
+ Requires-Dist: tree-sitter-javascript>=0.21.0
15
+ Requires-Dist: tree-sitter-typescript>=0.21.0
16
+ Requires-Dist: networkx>=3.0
17
+ Requires-Dist: gitpython>=3.1.0
18
+ Requires-Dist: tiktoken>=0.5.0
19
+ Requires-Dist: pyyaml>=6.0
20
+ Requires-Dist: certifi>=2023.7.22
21
+ Dynamic: license-file
22
+
23
+ # Husk
24
+
25
+ *Your codebase's past, present, and architecture—laid bare.*
26
+
27
+ Understand legacy codebases quickly and privately with offline static analysis and local LLMs, without uploading code or fighting heavy setups.
28
+
29
+ ---
30
+
31
+ ## ❓ Why Husk?
32
+ > Because code archaeology should be secure, local, and instant — without SaaS lock-in or platform-specific vector databases.
33
+
34
+ ---
35
+
36
+ ## 🚀 Key Features
37
+
38
+ * **Static Analysis (Tier 1):** Code complexity, git churn, hotspots rankings, dead code detection, and Mermaid dependency graph exports.
39
+ * **Semantic AI (Tier 2):** Hierarchical Map-Reduce documentation generator, token/budget limits, and natural language Q&A index with Git Blame annotations.
40
+ * **Remote Git Scanning:** Provide a Git clone link (`https://github.com/...`) instead of a local directory, and Husk will automatically download and scan it temporarily.
41
+ * **Local Offline AI:** Fully integrates with local Ollama servers (like `llama3`) for offline, zero-cost semantic search and summarization.
42
+
43
+ ---
44
+
45
+ ## 📦 Quick Start
46
+
47
+ ```bash
48
+ # Set up environment
49
+ python3 -m venv .venv
50
+ source .venv/bin/activate
51
+ pip install -r requirements.txt && pip install -e .
52
+
53
+ # Configure LLM credentials (or select Ollama for local model)
54
+ husk init
55
+
56
+ # Scan a local folder or remote repository link
57
+ husk scan https://github.com/example/project.git --detailed
58
+
59
+ # Generate documentation suite statically or with AI
60
+ husk doc . --with-ai
61
+
62
+ # Query codebase using RAG
63
+ husk ask "how does authentication work?" https://github.com/example/project.git
64
+ ```
65
+
66
+ ---
67
+
68
+ ## 🛠️ CLI Commands
69
+
70
+ * `husk scan [repo_path_or_url] [--detailed]`: Inventory source files and code symbols.
71
+ * `husk graph [repo_path_or_url] [--output path]`: Export visual Mermaid dependency flows.
72
+ * `husk hotspots [repo_path_or_url]`: Rank files by complexity × churn risks.
73
+ * `husk deadcode [repo_path_or_url]`: List unreferenced files (in-degree = 0).
74
+ * `husk init`: Configure LLM credentials (OpenAI, Anthropic, Ollama).
75
+ * `husk doc [repo_path_or_url] [--with-ai]`: Generate structured documentation under `/docs`.
76
+ * `husk ask "query" [repo_path_or_url]`: Ask natural language questions with git blame context.
@@ -0,0 +1,54 @@
1
+ # Husk
2
+
3
+ *Your codebase's past, present, and architecture—laid bare.*
4
+
5
+ Understand legacy codebases quickly and privately with offline static analysis and local LLMs, without uploading code or fighting heavy setups.
6
+
7
+ ---
8
+
9
+ ## ❓ Why Husk?
10
+ > Because code archaeology should be secure, local, and instant — without SaaS lock-in or platform-specific vector databases.
11
+
12
+ ---
13
+
14
+ ## 🚀 Key Features
15
+
16
+ * **Static Analysis (Tier 1):** Code complexity, git churn, hotspots rankings, dead code detection, and Mermaid dependency graph exports.
17
+ * **Semantic AI (Tier 2):** Hierarchical Map-Reduce documentation generator, token/budget limits, and natural language Q&A index with Git Blame annotations.
18
+ * **Remote Git Scanning:** Provide a Git clone link (`https://github.com/...`) instead of a local directory, and Husk will automatically download and scan it temporarily.
19
+ * **Local Offline AI:** Fully integrates with local Ollama servers (like `llama3`) for offline, zero-cost semantic search and summarization.
20
+
21
+ ---
22
+
23
+ ## 📦 Quick Start
24
+
25
+ ```bash
26
+ # Set up environment
27
+ python3 -m venv .venv
28
+ source .venv/bin/activate
29
+ pip install -r requirements.txt && pip install -e .
30
+
31
+ # Configure LLM credentials (or select Ollama for local model)
32
+ husk init
33
+
34
+ # Scan a local folder or remote repository link
35
+ husk scan https://github.com/example/project.git --detailed
36
+
37
+ # Generate documentation suite statically or with AI
38
+ husk doc . --with-ai
39
+
40
+ # Query codebase using RAG
41
+ husk ask "how does authentication work?" https://github.com/example/project.git
42
+ ```
43
+
44
+ ---
45
+
46
+ ## 🛠️ CLI Commands
47
+
48
+ * `husk scan [repo_path_or_url] [--detailed]`: Inventory source files and code symbols.
49
+ * `husk graph [repo_path_or_url] [--output path]`: Export visual Mermaid dependency flows.
50
+ * `husk hotspots [repo_path_or_url]`: Rank files by complexity × churn risks.
51
+ * `husk deadcode [repo_path_or_url]`: List unreferenced files (in-degree = 0).
52
+ * `husk init`: Configure LLM credentials (OpenAI, Anthropic, Ollama).
53
+ * `husk doc [repo_path_or_url] [--with-ai]`: Generate structured documentation under `/docs`.
54
+ * `husk ask "query" [repo_path_or_url]`: Ask natural language questions with git blame context.
@@ -0,0 +1,5 @@
1
+ from husk.crawler import RepoCrawler
2
+ from husk.parser import CodeParser
3
+ from husk.graph import DependencyGraphBuilder
4
+
5
+ __version__ = "0.1.0"
@@ -0,0 +1 @@
1
+ # Husk AI Subpackage
@@ -0,0 +1,186 @@
1
+ import os
2
+ import json
3
+ import urllib.request
4
+ import urllib.error
5
+ import ssl
6
+ from abc import ABC, abstractmethod
7
+ from typing import Dict, Any, Optional
8
+
9
+ def get_ssl_context():
10
+ """
11
+ Returns an SSL context. Prefers certifi's bundle if available.
12
+ """
13
+ try:
14
+ import certifi
15
+ return ssl.create_default_context(cafile=certifi.where())
16
+ except Exception:
17
+ try:
18
+ return ssl._create_unverified_context()
19
+ except AttributeError:
20
+ return None
21
+
22
+ class BaseAdapter(ABC):
23
+ """
24
+ Abstract Base Class for LLM API Adapters.
25
+ """
26
+ def __init__(self, api_key: Optional[str] = None, model: Optional[str] = None):
27
+ self.api_key = api_key
28
+ self.model = model
29
+
30
+ @abstractmethod
31
+ def generate(self, prompt: str, system: str = "") -> str:
32
+ """
33
+ Sends a request to the LLM provider and returns the text response.
34
+ """
35
+ pass
36
+
37
+ class OpenAIAdapter(BaseAdapter):
38
+ """
39
+ Adapter for OpenAI completions.
40
+ """
41
+ def __init__(self, api_key: Optional[str] = None, model: Optional[str] = None):
42
+ # Fallback to environment variable
43
+ key = api_key or os.environ.get("OPENAI_API_KEY")
44
+ mdl = model or "gpt-4o-mini"
45
+ super().__init__(key, mdl)
46
+
47
+ def generate(self, prompt: str, system: str = "") -> str:
48
+ if not self.api_key:
49
+ raise ValueError("OpenAI API Key is missing. Set OPENAI_API_KEY or configure it in husk.")
50
+
51
+ url = "https://api.openai.com/v1/chat/completions"
52
+ headers = {
53
+ "Content-Type": "application/json",
54
+ "Authorization": f"Bearer {self.api_key}"
55
+ }
56
+
57
+ messages = []
58
+ if system:
59
+ messages.append({"role": "system", "content": system})
60
+ messages.append({"role": "user", "content": prompt})
61
+
62
+ payload = {
63
+ "model": self.model,
64
+ "messages": messages,
65
+ "temperature": 0.2
66
+ }
67
+
68
+ req = urllib.request.Request(
69
+ url,
70
+ data=json.dumps(payload).encode("utf-8"),
71
+ headers=headers,
72
+ method="POST"
73
+ )
74
+
75
+ try:
76
+ with urllib.request.urlopen(req, timeout=30, context=get_ssl_context()) as response:
77
+ res_data = json.loads(response.read().decode("utf-8"))
78
+ return res_data["choices"][0]["message"]["content"]
79
+ except urllib.error.HTTPError as e:
80
+ err_body = e.read().decode("utf-8", errors="ignore")
81
+ raise RuntimeError(f"OpenAI HTTP Error {e.code}: {err_body}")
82
+ except Exception as e:
83
+ raise RuntimeError(f"Failed to communicate with OpenAI: {e}")
84
+
85
+ class AnthropicAdapter(BaseAdapter):
86
+ """
87
+ Adapter for Anthropic messages API.
88
+ """
89
+ def __init__(self, api_key: Optional[str] = None, model: Optional[str] = None):
90
+ key = api_key or os.environ.get("ANTHROPIC_API_KEY")
91
+ mdl = model or "claude-3-5-sonnet-20241022"
92
+ super().__init__(key, mdl)
93
+
94
+ def generate(self, prompt: str, system: str = "") -> str:
95
+ if not self.api_key:
96
+ raise ValueError("Anthropic API Key is missing. Set ANTHROPIC_API_KEY or configure it in husk.")
97
+
98
+ url = "https://api.anthropic.com/v1/messages"
99
+ headers = {
100
+ "content-type": "application/json",
101
+ "x-api-key": self.api_key,
102
+ "anthropic-version": "2023-06-01"
103
+ }
104
+
105
+ payload = {
106
+ "model": self.model,
107
+ "max_tokens": 2048,
108
+ "messages": [{"role": "user", "content": prompt}]
109
+ }
110
+ if system:
111
+ payload["system"] = system
112
+
113
+ req = urllib.request.Request(
114
+ url,
115
+ data=json.dumps(payload).encode("utf-8"),
116
+ headers=headers,
117
+ method="POST"
118
+ )
119
+
120
+ try:
121
+ with urllib.request.urlopen(req, timeout=30, context=get_ssl_context()) as response:
122
+ res_data = json.loads(response.read().decode("utf-8"))
123
+ return res_data["content"][0]["text"]
124
+ except urllib.error.HTTPError as e:
125
+ err_body = e.read().decode("utf-8", errors="ignore")
126
+ raise RuntimeError(f"Anthropic HTTP Error {e.code}: {err_body}")
127
+ except Exception as e:
128
+ raise RuntimeError(f"Failed to communicate with Anthropic: {e}")
129
+
130
+ class OllamaAdapter(BaseAdapter):
131
+ """
132
+ Adapter for local Ollama instances.
133
+ """
134
+ def __init__(self, host: Optional[str] = None, model: Optional[str] = None):
135
+ # host maps to api_key argument in base
136
+ hst = host or os.environ.get("OLLAMA_HOST") or "http://localhost:11434"
137
+ mdl = model or "llama3"
138
+ super().__init__(hst, mdl)
139
+
140
+ def generate(self, prompt: str, system: str = "") -> str:
141
+ url = f"{self.api_key}/api/generate"
142
+ headers = {
143
+ "Content-Type": "application/json"
144
+ }
145
+
146
+ payload = {
147
+ "model": self.model,
148
+ "prompt": prompt,
149
+ "stream": False
150
+ }
151
+ if system:
152
+ payload["system"] = system
153
+
154
+ req = urllib.request.Request(
155
+ url,
156
+ data=json.dumps(payload).encode("utf-8"),
157
+ headers=headers,
158
+ method="POST"
159
+ )
160
+
161
+ try:
162
+ with urllib.request.urlopen(req, timeout=300, context=get_ssl_context()) as response:
163
+ res_data = json.loads(response.read().decode("utf-8"))
164
+ return res_data["response"]
165
+ except urllib.error.HTTPError as e:
166
+ err_body = e.read().decode("utf-8", errors="ignore")
167
+ raise RuntimeError(f"Ollama HTTP Error {e.code}: {err_body}")
168
+ except Exception as e:
169
+ import socket
170
+ if isinstance(e, socket.timeout) or "timed out" in str(e).lower():
171
+ raise RuntimeError(f"Ollama request timed out: {e}")
172
+ raise RuntimeError(f"Failed to communicate with Ollama at {self.api_key}: {e}. Ensure Ollama is running.")
173
+
174
+ def get_adapter(provider: str, api_key: Optional[str] = None, model: Optional[str] = None) -> BaseAdapter:
175
+ """
176
+ Factory function to retrieve LLM adapter based on provider name.
177
+ """
178
+ prov = provider.lower()
179
+ if prov == "openai":
180
+ return OpenAIAdapter(api_key, model)
181
+ elif prov == "anthropic":
182
+ return AnthropicAdapter(api_key, model)
183
+ elif prov in ("ollama", "local"):
184
+ return OllamaAdapter(api_key, model) # api_key stores host URL for Ollama
185
+ else:
186
+ raise ValueError(f"Unknown LLM provider: {provider}")
@@ -0,0 +1,106 @@
1
+ import os
2
+ import json
3
+ import hashlib
4
+ from typing import Dict, Optional
5
+
6
+ class FileCache:
7
+ """
8
+ Manages local filesystem caching for file analysis results using content SHA256 hashes.
9
+ Stores data in `.husk/cache.json`.
10
+ """
11
+ def __init__(self, repo_path: str):
12
+ self.repo_path = os.path.abspath(repo_path)
13
+ self.husk_dir = os.path.join(self.repo_path, ".husk")
14
+ self.cache_file = os.path.join(self.husk_dir, "cache.json")
15
+ self.cache_data: Dict[str, Dict[str, str]] = {}
16
+ self.load()
17
+
18
+ def load(self):
19
+ """
20
+ Loads the cache from disk.
21
+ """
22
+ if os.path.exists(self.cache_file):
23
+ try:
24
+ with open(self.cache_file, "r") as f:
25
+ self.cache_data = json.load(f)
26
+ except Exception:
27
+ self.cache_data = {}
28
+ else:
29
+ self.cache_data = {}
30
+
31
+ def save(self):
32
+ """
33
+ Saves the cache to disk.
34
+ """
35
+ try:
36
+ os.makedirs(self.husk_dir, exist_ok=True)
37
+ with open(self.cache_file, "w") as f:
38
+ json.dump(self.cache_data, f, indent=2)
39
+ except Exception:
40
+ pass
41
+
42
+ @staticmethod
43
+ def compute_sha256(file_path: str) -> str:
44
+ """
45
+ Computes the SHA256 hash of a file's content.
46
+ """
47
+ if not os.path.exists(file_path):
48
+ return ""
49
+ hasher = hashlib.sha256()
50
+ with open(file_path, "rb") as f:
51
+ for chunk in iter(lambda: f.read(4096), b""):
52
+ hasher.update(chunk)
53
+ return hasher.hexdigest()
54
+
55
+ def get_summary(self, rel_path: str, file_path: str) -> Optional[str]:
56
+ """
57
+ Retrieves the cached summary for a file if the content hash matches.
58
+ """
59
+ sha = self.compute_sha256(file_path)
60
+ if not sha:
61
+ return None
62
+
63
+ entry = self.cache_data.get(rel_path)
64
+ if entry and entry.get("sha256") == sha:
65
+ return entry.get("summary")
66
+ return None
67
+
68
+ def set_summary(self, rel_path: str, file_path: str, summary: str):
69
+ """
70
+ Updates the cached summary for a file.
71
+ """
72
+ sha = self.compute_sha256(file_path)
73
+ if not sha:
74
+ return
75
+
76
+ self.cache_data[rel_path] = {
77
+ "sha256": sha,
78
+ "summary": summary
79
+ }
80
+ self.save()
81
+
82
+ def get_dir_summary(self, rel_path: str, combined_hash: str) -> Optional[str]:
83
+ """
84
+ Retrieves directory summary if the combined hash matches.
85
+ """
86
+ entry = self.cache_data.get(rel_path)
87
+ if entry and entry.get("sha256") == combined_hash:
88
+ return entry.get("summary")
89
+ return None
90
+
91
+ def set_dir_summary(self, rel_path: str, combined_hash: str, summary: str):
92
+ """
93
+ Sets directory summary with combined hash.
94
+ """
95
+ self.cache_data[rel_path] = {
96
+ "sha256": combined_hash,
97
+ "summary": summary
98
+ }
99
+ self.save()
100
+
101
+ def clear(self):
102
+ """
103
+ Clears the cache data.
104
+ """
105
+ self.cache_data = {}
106
+ self.save()
@@ -0,0 +1,66 @@
1
+ from typing import Dict, Tuple
2
+
3
+ # Pricing per 1,000,000 tokens (in USD)
4
+ MODEL_PRICING: Dict[str, Dict[str, float]] = {
5
+ # OpenAI
6
+ "gpt-4o-mini": {"input": 0.15, "output": 0.60},
7
+ "gpt-4o": {"input": 2.50, "output": 10.00},
8
+ # Anthropic
9
+ "claude-3-5-sonnet-20241022": {"input": 3.00, "output": 15.00},
10
+ "claude-3-5-haiku-20241022": {"input": 0.80, "output": 4.00},
11
+ # Ollama / Local
12
+ "llama3": {"input": 0.0, "output": 0.0},
13
+ "mistral": {"input": 0.0, "output": 0.0},
14
+ }
15
+
16
+ class TokenEstimator:
17
+ """
18
+ Handles local token estimation and cost calculation for LLM processing.
19
+ """
20
+ def __init__(self, model_name: str):
21
+ self.model_name = model_name
22
+ self.pricing = MODEL_PRICING.get(model_name, {"input": 0.50, "output": 1.50}) # fallback default
23
+
24
+ @staticmethod
25
+ def count_tokens(text: str) -> int:
26
+ """
27
+ Estimates the token count of a given text.
28
+ Uses tiktoken if installed, otherwise falls back to character approximation.
29
+ """
30
+ try:
31
+ import tiktoken
32
+ # cl100k_base is the standard vocabulary for GPT-4 and Claude tokens are closely aligned
33
+ encoding = tiktoken.get_encoding("cl100k_base")
34
+ return len(encoding.encode(text))
35
+ except ImportError:
36
+ # Fallback approximation: ~4 characters per token
37
+ return len(text) // 4
38
+
39
+ def calculate_cost(self, total_tokens: int, estimated_completion_ratio: float = 0.25) -> Tuple[float, int, int]:
40
+ """
41
+ Calculates the estimated cost (in USD) for processing total_tokens.
42
+
43
+ Args:
44
+ total_tokens: Number of prompt/input tokens.
45
+ estimated_completion_ratio: Multiplier to estimate completion output tokens.
46
+
47
+ Returns:
48
+ Tuple: (Estimated Cost USD, Input Tokens, Output Tokens)
49
+ """
50
+ input_tokens = total_tokens
51
+ output_tokens = int(total_tokens * estimated_completion_ratio)
52
+
53
+ input_cost = (input_tokens / 1_000_000) * self.pricing["input"]
54
+ output_cost = (output_tokens / 1_000_000) * self.pricing["output"]
55
+
56
+ total_cost = input_cost + output_cost
57
+ return total_cost, input_tokens, output_tokens
58
+
59
+ @staticmethod
60
+ def format_cost(cost_usd: float) -> str:
61
+ """
62
+ Formats cost in a user-friendly way.
63
+ """
64
+ if cost_usd < 0.01:
65
+ return f"${cost_usd:.5f}"
66
+ return f"${cost_usd:.2f}"