hubble-cli 4.0.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.
hubble/web.py ADDED
@@ -0,0 +1,279 @@
1
+ """Web tools: web_search (DuckDuckGo by default, Brave/Tavily with an API key) and web_fetch.
2
+
3
+ web_fetch refuses loopback/private/link-local addresses, so the model cannot probe the
4
+ user's local network or cloud metadata endpoints.
5
+ """
6
+
7
+ import html
8
+ import ipaddress
9
+ import os
10
+ import re
11
+ import socket
12
+ from html.parser import HTMLParser
13
+ from typing import Any, Dict, List, Optional
14
+ from urllib.parse import parse_qs, unquote, urlparse
15
+
16
+ import httpx
17
+
18
+ from hubble.tools import Tool, ToolContext, ToolError
19
+
20
+ USER_AGENT = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) "
21
+ "Chrome/124.0 Safari/537.36 Hubble-CLI")
22
+ TIMEOUT = httpx.Timeout(connect=10.0, read=25.0, write=10.0, pool=10.0)
23
+ MAX_DOWNLOAD = 3_000_000
24
+ MAX_RESULTS = 10
25
+
26
+
27
+ # ----- search backends ------------------------------------------------------
28
+
29
+ def _strip_tags(s: str) -> str:
30
+ return html.unescape(re.sub(r"<[^>]+>", "", s)).strip()
31
+
32
+
33
+ def search_duckduckgo(query: str, limit: int) -> List[Dict[str, str]]:
34
+ resp = httpx.post("https://html.duckduckgo.com/html/", data={"q": query},
35
+ headers={"User-Agent": USER_AGENT}, timeout=TIMEOUT, follow_redirects=True)
36
+ if resp.status_code != 200:
37
+ raise ToolError(f"DuckDuckGo returned HTTP {resp.status_code}")
38
+ page = resp.text
39
+ if "anomaly" in page.lower() and "result__a" not in page:
40
+ raise ToolError("DuckDuckGo asked for a captcha; set a Brave or Tavily API key for reliable search")
41
+ results = []
42
+ blocks = re.split(r'<div[^>]+class="[^"]*result(?:s_links)?[^"]*"', page)
43
+ for block in blocks:
44
+ link = re.search(r'<a[^>]*class="result__a"[^>]*href="([^"]+)"[^>]*>(.*?)</a>', block, re.S)
45
+ if not link:
46
+ continue
47
+ href = html.unescape(link.group(1))
48
+ if "duckduckgo.com/l/" in href: # redirect wrapper: real URL is in uddg=
49
+ href = unquote(parse_qs(urlparse(href).query).get("uddg", [href])[0])
50
+ if href.startswith("//"):
51
+ href = "https:" + href
52
+ if "duckduckgo.com/y.js" in href: # ads
53
+ continue
54
+ snippet = re.search(r'class="result__snippet"[^>]*>(.*?)</a>', block, re.S)
55
+ results.append({"title": _strip_tags(link.group(2)), "url": href,
56
+ "snippet": _strip_tags(snippet.group(1)) if snippet else ""})
57
+ if len(results) >= limit:
58
+ break
59
+ return results
60
+
61
+
62
+ def search_brave(query: str, limit: int, key: str) -> List[Dict[str, str]]:
63
+ resp = httpx.get("https://api.search.brave.com/res/v1/web/search", params={"q": query, "count": limit},
64
+ headers={"X-Subscription-Token": key, "Accept": "application/json"}, timeout=TIMEOUT)
65
+ if resp.status_code != 200:
66
+ raise ToolError(f"Brave Search returned HTTP {resp.status_code}")
67
+ items = (resp.json().get("web") or {}).get("results") or []
68
+ return [{"title": i.get("title", ""), "url": i.get("url", ""), "snippet": _strip_tags(i.get("description", ""))}
69
+ for i in items[:limit]]
70
+
71
+
72
+ def search_tavily(query: str, limit: int, key: str) -> List[Dict[str, str]]:
73
+ resp = httpx.post("https://api.tavily.com/search", json={"api_key": key, "query": query, "max_results": limit},
74
+ timeout=TIMEOUT)
75
+ if resp.status_code != 200:
76
+ raise ToolError(f"Tavily returned HTTP {resp.status_code}")
77
+ return [{"title": i.get("title", ""), "url": i.get("url", ""), "snippet": (i.get("content") or "")[:300]}
78
+ for i in (resp.json().get("results") or [])[:limit]]
79
+
80
+
81
+ def web_search(query: str, limit: int = 8, config: Optional[Dict[str, Any]] = None) -> List[Dict[str, str]]:
82
+ cfg = config or {}
83
+ brave = cfg.get("brave_api_key") or os.environ.get("BRAVE_API_KEY")
84
+ tavily = cfg.get("tavily_api_key") or os.environ.get("TAVILY_API_KEY")
85
+ engine = (cfg.get("engine") or "auto").lower()
86
+ if engine in ("brave", "auto") and brave:
87
+ return search_brave(query, limit, brave)
88
+ if engine in ("tavily", "auto") and tavily:
89
+ return search_tavily(query, limit, tavily)
90
+ try:
91
+ return search_duckduckgo(query, limit)
92
+ except httpx.HTTPError as e:
93
+ raise ToolError(f"search failed: {type(e).__name__}: {e}")
94
+
95
+
96
+ # ----- fetch -----------------------------------------------------------------
97
+
98
+ class _TextExtractor(HTMLParser):
99
+ SKIP = {"script", "style", "noscript", "svg", "nav", "footer", "form", "iframe", "template", "head"}
100
+ BLOCK = {"p", "div", "section", "article", "main", "br", "li", "ul", "ol", "table", "tr", "pre",
101
+ "blockquote", "h1", "h2", "h3", "h4", "h5", "h6", "header", "aside", "dd", "dt"}
102
+
103
+ def __init__(self, base: str):
104
+ super().__init__(convert_charrefs=True)
105
+ self.base = base
106
+ self.out: List[str] = []
107
+ self.skip = 0
108
+ self.title = ""
109
+ self._in_title = False
110
+ self._href: Optional[str] = None
111
+ self._link_start = 0
112
+ self._pre = 0
113
+
114
+ def handle_starttag(self, tag, attrs):
115
+ if tag == "title":
116
+ self._in_title = True
117
+ if tag in self.SKIP:
118
+ self.skip += 1
119
+ return
120
+ if self.skip:
121
+ return
122
+ if tag in ("h1", "h2", "h3", "h4"):
123
+ self.out.append("\n\n" + "#" * int(tag[1]) + " ")
124
+ elif tag == "li":
125
+ self.out.append("\n- ")
126
+ elif tag == "pre":
127
+ self._pre += 1
128
+ self.out.append("\n```\n")
129
+ elif tag in self.BLOCK:
130
+ self.out.append("\n")
131
+ elif tag == "a":
132
+ href = dict(attrs).get("href") or ""
133
+ self._href = href if href.startswith("http") else None
134
+ self._link_start = len(self.out)
135
+
136
+ def handle_endtag(self, tag):
137
+ if tag == "title":
138
+ self._in_title = False
139
+ if tag in self.SKIP:
140
+ self.skip = max(0, self.skip - 1)
141
+ return
142
+ if self.skip:
143
+ return
144
+ if tag == "pre":
145
+ self._pre = max(0, self._pre - 1)
146
+ self.out.append("\n```\n")
147
+ elif tag == "a" and self._href:
148
+ if "".join(self.out[self._link_start:]).strip(): # skip icon/image-only links
149
+ self.out.append(f" ({self._href})")
150
+ self._href = None
151
+ elif tag in self.BLOCK:
152
+ self.out.append("\n")
153
+
154
+ def handle_data(self, data):
155
+ if self._in_title:
156
+ self.title += data
157
+ if self.skip:
158
+ return
159
+ self.out.append(data if self._pre else re.sub(r"\s+", " ", data))
160
+
161
+ def text(self) -> str:
162
+ raw = "".join(self.out)
163
+ raw = re.sub(r"[ \t]+\n", "\n", raw)
164
+ raw = re.sub(r"\n{3,}", "\n\n", raw)
165
+ return raw.strip()
166
+
167
+
168
+ def _check_public(url: str) -> str:
169
+ parsed = urlparse(url)
170
+ if parsed.scheme not in ("http", "https") or not parsed.hostname:
171
+ raise ToolError("only http(s) URLs can be fetched")
172
+ host = parsed.hostname
173
+ try:
174
+ infos = socket.getaddrinfo(host, parsed.port or (443 if parsed.scheme == "https" else 80))
175
+ except socket.gaierror:
176
+ raise ToolError(f"could not resolve {host}")
177
+ for info in infos:
178
+ ip = ipaddress.ip_address(info[4][0].split("%")[0])
179
+ if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_multicast:
180
+ raise ToolError(f"{host} resolves to a private or local address ({ip}); refusing to fetch it")
181
+ return host
182
+
183
+
184
+ def fetch_url(url: str, max_chars: int = 30000, offset: int = 0) -> str:
185
+ if not re.match(r"^https?://", url):
186
+ url = "https://" + url
187
+ _check_public(url)
188
+ try:
189
+ with httpx.Client(timeout=TIMEOUT, follow_redirects=False, headers={"User-Agent": USER_AGENT}) as client:
190
+ for _ in range(5): # follow redirects manually so every hop is checked
191
+ with client.stream("GET", url) as resp:
192
+ if resp.is_redirect and resp.headers.get("location"):
193
+ url = str(resp.url.join(resp.headers["location"]))
194
+ _check_public(url)
195
+ continue
196
+ if resp.status_code >= 400:
197
+ raise ToolError(f"HTTP {resp.status_code} for {url}")
198
+ ctype = resp.headers.get("content-type", "")
199
+ body = b""
200
+ for chunk in resp.iter_bytes():
201
+ body += chunk
202
+ if len(body) > MAX_DOWNLOAD:
203
+ break
204
+ break
205
+ else:
206
+ raise ToolError("too many redirects")
207
+ except httpx.HTTPError as e:
208
+ raise ToolError(f"fetch failed: {type(e).__name__}: {e}")
209
+
210
+ text = body.decode(resp.encoding or "utf-8", errors="replace")
211
+ if "html" in ctype or text.lstrip()[:15].lower().startswith(("<!doctype", "<html")):
212
+ parser = _TextExtractor(url)
213
+ parser.feed(text)
214
+ title = parser.title.strip()
215
+ text = (f"# {title}\n\n" if title else "") + parser.text()
216
+ elif not any(t in ctype for t in ("text", "json", "xml", "javascript")) and ctype:
217
+ raise ToolError(f"unsupported content type {ctype}")
218
+ # Continuous window, not a middle cut: metadata often sits mid-page (sidebars, release info).
219
+ offset = max(0, min(offset, len(text)))
220
+ chunk = text[offset:offset + max_chars]
221
+ end = offset + len(chunk)
222
+ header = f"[{url} · chars {offset}-{end} of {len(text)}]"
223
+ more = (f"\n\n[... {len(text) - end} more chars. Call web_fetch with the same url and offset={end} "
224
+ "to read on.]") if end < len(text) else ""
225
+ return f"{header}\n\n{chunk}{more}"
226
+
227
+
228
+ # ----- tools -----------------------------------------------------------------
229
+
230
+ class WebSearch(Tool):
231
+ name = "web_search"
232
+ description = ("Search the web. Returns titles, URLs and snippets. Use for current information, docs, "
233
+ "error messages and library versions; then web_fetch the most relevant result.")
234
+ parameters = {"type": "object", "properties": {
235
+ "query": {"type": "string", "description": "Search query"},
236
+ "max_results": {"type": "integer", "description": f"1-{MAX_RESULTS} (default 8)"},
237
+ }, "required": ["query"]}
238
+ kind = "read"
239
+
240
+ def __init__(self, config: Optional[Dict[str, Any]] = None):
241
+ self.config = config or {}
242
+
243
+ def target(self, args):
244
+ return args.get("query", "")
245
+
246
+ def run(self, args, ctx: ToolContext):
247
+ limit = max(1, min(int(args.get("max_results") or 8), MAX_RESULTS))
248
+ results = web_search(args["query"], limit, self.config)
249
+ if not results:
250
+ return f"No results for '{args['query']}'."
251
+ lines = [f"Results for '{args['query']}':"]
252
+ for i, r in enumerate(results, 1):
253
+ lines.append(f"{i}. {r['title']}\n {r['url']}\n {r['snippet'][:300]}")
254
+ return "\n".join(lines)
255
+
256
+
257
+ class WebFetch(Tool):
258
+ name = "web_fetch"
259
+ description = ("Fetch a web page or text file by URL and return its readable text (HTML is converted "
260
+ "to plain text with headings and links). Use after web_search, or for URLs the user gives.")
261
+ parameters = {"type": "object", "properties": {
262
+ "url": {"type": "string", "description": "http(s) URL"},
263
+ "max_chars": {"type": "integer", "description": "Max characters to return (default 30000)"},
264
+ "offset": {"type": "integer", "description": "Character offset to continue a long page (default 0)"},
265
+ }, "required": ["url"]}
266
+ kind = "web"
267
+
268
+ def target(self, args):
269
+ """Permission rules match the domain, e.g. web_fetch(docs.python.org)."""
270
+ url = args.get("url", "")
271
+ host = urlparse(url if "://" in url else "https://" + url).hostname or url
272
+ return host.lower()
273
+
274
+ def preview(self, args, ctx):
275
+ return args.get("url", "")
276
+
277
+ def run(self, args, ctx: ToolContext):
278
+ return fetch_url(args["url"], max(1000, min(int(args.get("max_chars") or 30000), 60000)),
279
+ int(args.get("offset") or 0))
@@ -0,0 +1,275 @@
1
+ Metadata-Version: 2.4
2
+ Name: hubble-cli
3
+ Version: 4.0.0
4
+ Summary: Hubble: agentic coding CLI for AIHub and other OpenAI-compatible APIs
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/Hamdayrabby/hubble-cli
7
+ Project-URL: Repository, https://github.com/Hamdayrabby/hubble-cli
8
+ Project-URL: Changelog, https://github.com/Hamdayrabby/hubble-cli/blob/main/CHANGELOG.md
9
+ Keywords: cli,agent,coding-assistant,llm,ai
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Software Development
19
+ Classifier: Topic :: Utilities
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: httpx>=0.25.0
24
+ Requires-Dist: rich>=13.0
25
+ Requires-Dist: prompt_toolkit>=3.0.40
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=7; extra == "dev"
28
+ Dynamic: license-file
29
+
30
+ # Hubble
31
+
32
+ An agentic coding CLI in the style of Claude Code, Antigravity/Gemini CLI and Codex CLI. It works directly
33
+ in your repository: it searches, reads and edits files and runs commands through native function calling,
34
+ and you approve each action. It talks to any OpenAI-compatible API — the AIHub gateway by default, or
35
+ OpenAI, Groq, OpenRouter, Mistral, a local Ollama server, or others via `/provider add`.
36
+
37
+ ## Install
38
+
39
+ ```bash
40
+ pipx install git+https://github.com/Hamdayrabby/hubble-cli.git
41
+ ```
42
+ (or `pip install --user git+https://github.com/Hamdayrabby/hubble-cli.git` if you don't use pipx)
43
+
44
+ Then just run it from any project:
45
+
46
+ ```bash
47
+ cd /path/to/your/project
48
+ hubble
49
+ ```
50
+
51
+ With no API key configured anywhere, the first run walks you through adding one — no manual `.env` editing
52
+ required. To set one up yourself instead, put it in `.env` in your project or in `~/.hubble/.env`:
53
+ ```
54
+ HUBBLE_API_KEY=your_key_here
55
+ HUBBLE_BASE_URL=https://aihub.071129.xyz/v1 # or any other OpenAI-compatible base URL
56
+ ```
57
+
58
+ **From a local clone**, for development: `pip install -e .` from the repo root installs the `hubble`
59
+ command (`aihub` also works, kept as an alias) against your working copy — edits take effect immediately.
60
+ Without installing at all: `python code_cli.py [args]` from the repo root (add `--cwd <project>` to work
61
+ elsewhere). The original single-file prototype is still there as `python chat_cli.py`.
62
+
63
+ ## Usage
64
+
65
+ ```bash
66
+ hubble # interactive REPL
67
+ hubble "explain the architecture" # REPL with a first prompt
68
+ hubble -c # continue the latest session in this folder
69
+ hubble -r # choose a session to resume
70
+ hubble -p "fix the failing test" --permission-mode accept-edits --allow "shell(pytest*)"
71
+ git diff | hubble -p "review this diff" --output-format json
72
+ hubble -m nvidia/nemotron-3-super-120b-a12b --persona architect
73
+ hubble --test codestral-latest # check that a model responds
74
+ ```
75
+
76
+ ## Tools the model can use
77
+
78
+ | Tool | What it does |
79
+ |---|---|
80
+ | `read_file` | Read with line numbers, `offset`/`limit` for large files |
81
+ | `edit_file` | Exact string replace; must be unique unless `replace_all`; keeps CRLF |
82
+ | `write_file` | Create or overwrite a file (existing files must be read first) |
83
+ | `shell` | Run a command in the workspace root (PowerShell on Windows), with timeout and closed stdin |
84
+ | `grep` | Regex search (ripgrep if installed, otherwise Python) |
85
+ | `glob` | Find files by pattern, newest first |
86
+ | `list_dir` | List a directory |
87
+ | `todo_write` | Task list for multi-step work, shown in the terminal |
88
+ | `task` | Read-only sub-agent for broad research; returns a report |
89
+ | `web_search` | Web search. DuckDuckGo by default (no key); set `BRAVE_API_KEY` or `TAVILY_API_KEY` to use those instead |
90
+ | `web_fetch` | Fetch a URL as readable text, page by page (`offset`). Asks once per domain; refuses local and private addresses |
91
+
92
+ Safety:
93
+ - **Workspace confinement:** paths outside the workspace are refused. Add others with `additional_dirs`.
94
+ - **Secret files are blocked:** `.env`, `*.pem`, `id_rsa` and similar are never read, searched or attached (`allow_secret_files` turns this off).
95
+ - **Edits need a fresh read:** a file must be read before it is edited or overwritten, and read again if it changed on disk since.
96
+ - **Undo:** every change is snapshotted, so `/undo` can revert it.
97
+
98
+ **Shell commands are not sandboxed by default** — they run directly on your machine with your own
99
+ permissions, same as anything you'd type yourself. Approval prompts are the only protection unless you
100
+ turn on the Docker sandbox below.
101
+
102
+ ### Sandboxed shell execution (optional)
103
+
104
+ With [Docker Desktop](https://www.docker.com/products/docker-desktop/) installed, shell commands can run
105
+ inside an isolated, disposable container instead of directly on your machine:
106
+
107
+ ```
108
+ /sandbox on
109
+ ```
110
+ or in `~/.hubble/settings.json` / `.hubble/settings.json`:
111
+ ```json
112
+ {
113
+ "shell_sandbox": "docker",
114
+ "sandbox_image": "python:3.12-slim",
115
+ "sandbox_memory": "1g",
116
+ "sandbox_cpus": "2",
117
+ "sandbox_network": true
118
+ }
119
+ ```
120
+ Only the project folder is mounted in (as `/workspace`); nothing else on your machine is reachable from
121
+ inside it. Memory and CPU are capped, and the container is removed after every command. Set
122
+ `sandbox_image` to whatever your project needs (e.g. `node:20` for a JS project); set `sandbox_network` to
123
+ `false` to also block network access from inside the sandbox, if your workflow doesn't need `pip`/`npm`
124
+ install-style commands. Like `permission_mode`, `shell_sandbox` and `sandbox_network` only take effect from
125
+ a project's own `.hubble/settings.json` once you've trusted that folder — an untrusted, freshly cloned
126
+ project can't quietly turn sandboxing off or re-enable network access on your behalf.
127
+
128
+ ## Permission modes
129
+
130
+ Cycle with **Shift+Tab** or set with `/mode` or `--permission-mode`:
131
+
132
+ | Mode | Behaviour |
133
+ |---|---|
134
+ | `default` | Ask before every edit and shell command. The prompt shows a diff or the command. |
135
+ | `accept-edits` | Edits are auto-approved. Shell commands still ask. |
136
+ | `plan` | Read-only. The model explores and proposes a plan. |
137
+ | `yolo` | Everything is auto-approved except deny rules. |
138
+
139
+ At an approval prompt:
140
+ - `y` allows the action once.
141
+ - `a` allows it for the rest of the session: all edits, or commands with the same prefix, e.g. `pytest*`.
142
+ - `n` denies it. You can add feedback, which is sent to the model.
143
+ - Ctrl+C stops the turn.
144
+
145
+ Allow rules never apply to chained commands (`&&`, `;`, `|`, redirects), so `shell(git status*)` does not approve `git status && rm -rf x`.
146
+
147
+ ## In-session commands
148
+
149
+ | Input | Action |
150
+ |---|---|
151
+ | `/help` | All commands and shortcuts |
152
+ | `/model [name\|#]`, `/models [filter]` | Switch or list models (latencies from `python test_models.py`) |
153
+ | `/mode [mode]` | Permission mode |
154
+ | `/persona [code\|debug\|review\|architect\|chat]` | System persona |
155
+ | `/clear` | New conversation |
156
+ | `/compact [focus]` | Summarize history to free context (automatic at 80%) |
157
+ | `/resume [#\|id]` | Resume a saved session |
158
+ | `/undo` | Revert files changed in the last turn that edited files |
159
+ | `/diff` | Show `git diff` |
160
+ | `/init` | Generate `HUBBLE.md` project instructions |
161
+ | `/memory` | Show loaded memory files |
162
+ | `/add <file>`, `/drop <file>`, `/files` | Pin files into the system prompt |
163
+ | `/todos`, `/cost`, `/context`, `/config`, `/test [model]`, `/temp [t]`, `/export [file]` | Info and utilities |
164
+ | `@path` | Attach a file (or directory listing) to your message; Tab completes paths |
165
+ | `!cmd` | Run a shell command yourself |
166
+ | `#note` | Append a note to `./HUBBLE.md` |
167
+ | Esc+Enter / Ctrl+J | New line |
168
+ | Ctrl+C / Ctrl+D | Cancel turn / quit |
169
+
170
+ **Custom commands:** `.hubble/commands/<name>.md` (project) or `~/.hubble/commands/<name>.md` (user) becomes
171
+ `/<name>`. `$ARGUMENTS` is replaced by the text after the command.
172
+
173
+ ## Providers (extra base URLs and API keys)
174
+
175
+ Any OpenAI-compatible API can be added next to the built-in AIHub gateway: OpenRouter, Groq, OpenAI, Mistral,
176
+ Gemini's OpenAI endpoint, a local Ollama server, and others.
177
+
178
+ 1. Type `/provider add`, or open `/provider` and choose **+ Add a provider**.
179
+ 2. Pick a known provider or **Custom URL...**, then paste the API key. The key is hidden as you type.
180
+ 3. The CLI checks the URL and key by listing the provider's models. If the check fails, it tells you why: wrong key, wrong URL, or can't connect.
181
+ 4. Optionally, the CLI checks which models actually respond, in the background. Progress shows in the bottom bar.
182
+ 5. Choose whether to switch to the new provider now. If you do, a model picker for that provider opens.
183
+
184
+ After that:
185
+ - `/model` lists models from every provider and switches provider automatically. The choice is saved as your default.
186
+ - `/provider` switches provider, `/provider list` shows all of them, and `/provider remove <name>` deletes one.
187
+ - `hubble --provider <name>` picks a provider for one run.
188
+ - On first start with no API key at all, the same setup runs instead of an error.
189
+
190
+ Extra providers are stored in `~/.hubble/providers.json`, with their API keys in plain text, like a `.env` file.
191
+ Their model lists are stored in `~/.hubble/models/<name>.json`. When a model is rate limited or down, Hubble retries that request with a fallback that the current provider actually has, in this order: the fallback you picked with `/fallback`, then `fallback_model` if the provider has it, then the provider's fastest verified model, then `fallback_model` on the built-in hubble provider. Sub-agents use the same route. `/fallback off` disables it for a provider.
192
+
193
+ ## Project memory
194
+
195
+ On startup the CLI loads `~/.hubble/HUBBLE.md`, then one of `HUBBLE.md`, `AGENTS.md`, `CLAUDE.md` or `GEMINI.md`
196
+ from each directory between the git root and the workspace, into the system prompt.
197
+
198
+ ## Configuration
199
+
200
+ Settings merge in order (later wins):
201
+ 1. Built-in defaults
202
+ 2. `~/.hubble/settings.json`
203
+ 3. `.hubble/settings.json`
204
+ 4. `.hubble/settings.local.json`
205
+ 5. CLI flags
206
+
207
+ ```json
208
+ {
209
+ "model": "codestral-latest",
210
+ "max_tokens": 8192,
211
+ "context_window": 128000,
212
+ "permission_mode": "default",
213
+ "shell": "auto",
214
+ "additional_dirs": [],
215
+ "permissions": {
216
+ "allow": ["shell(pytest*)", "shell(git status*)", "shell(git diff*)"],
217
+ "deny": ["shell(git push*)", "edit_file(migrations/*)"]
218
+ }
219
+ }
220
+ ```
221
+
222
+ - Rule syntax is `tool` or `tool(glob)`. The glob is matched against the path or the command.
223
+ - `bash`, `edit`, `write` and `read` work as aliases for the tool names.
224
+ - Deny rules win over allow rules.
225
+ - `shell` can be `auto` (pwsh, then Windows PowerShell), `cmd` or `bash`.
226
+
227
+ Project settings files (`.hubble/*.json`) come from the repository, so they are treated as untrusted:
228
+ - They can never set `base_url` or `api_key`.
229
+ - `permission_mode`, `allow_secret_files`, `additional_dirs`, `shell` and allow rules apply only after you trust the folder. The CLI asks once and remembers the answer in `~/.hubble/trusted_folders.json`.
230
+ - Deny rules always apply.
231
+
232
+ Credentials come from `HUBBLE_API_KEY` / `HUBBLE_BASE_URL` in the environment, `.env` in your project, or `~/.hubble/.env`.
233
+ Only `AIHUB_*` keys are read from those files.
234
+
235
+ Sessions are saved as JSONL in `~/.hubble/projects/<project>/`. Input history is in `~/.hubble/history`.
236
+
237
+ ## Layout
238
+
239
+ ```
240
+ hubble/
241
+ main.py CLI flags, headless -p mode, resume
242
+ repl.py prompt_toolkit REPL, slash commands, @mentions
243
+ ui.py rich rendering: streamed markdown, diffs, approval prompts
244
+ agent.py agent loop, compaction, task sub-agent
245
+ provider.py OpenAI-compatible SSE client, retries, tool-call assembly
246
+ tools.py workspace tools, sandbox, checkpoints
247
+ permissions.py modes and allow/deny rules
248
+ session.py JSONL transcripts
249
+ prompts.py system prompt, personas, memory files
250
+ settings.py layered settings and .env loading
251
+ models.py model registry (shared with config.py)
252
+ tests/ pytest suite (python -m pytest -q)
253
+ ```
254
+
255
+ ## Models on AIHub
256
+
257
+ Hubble checks which models actually respond in the background: automatically every time it starts (for
258
+ every provider you have configured), and on demand with `/models refresh`. `/models` shows the results and
259
+ how long ago they were checked. Set `"model_refresh_hours"` in `~/.hubble/settings.json` to a positive number
260
+ to only recheck once the list is that many hours old instead of on every start, or `null` to turn the
261
+ automatic check off entirely (`/models refresh` still works). The check is one tiny request per model, so it
262
+ costs a little on paid APIs.
263
+
264
+ Running `python test_models.py` does the same scan for the built-in provider from the command line.
265
+ The last scan found 33 working models out of 295.
266
+
267
+ | Category | Models |
268
+ |---|---|
269
+ | Coding (default) | `codestral-latest`, `codestral-2508`, `mistral-code-latest`, `mistral-code-fim-latest` |
270
+ | Reasoning | `nvidia/nemotron-3-super-120b-a12b`, `intern-s2-preview`, `intern-s1-mini`, `intern-s1` |
271
+ | Fast chat | `ministral-14b-latest`, `open-mistral-nemo`, `ministral-8b-latest`, `ministral-3b-latest` |
272
+ | Vision | `meta/llama-3.2-11b-vision-instruct`, `internvl3.5-latest`, `internvl-latest` |
273
+
274
+ Native tool calling was verified on `codestral-latest`, `mistral-code-latest`, `ministral-14b-latest` and
275
+ `nvidia/nemotron-3-super-120b-a12b`.
@@ -0,0 +1,27 @@
1
+ hubble/__init__.py,sha256=LEviQFt085eioP3wuOxDjdgZoLFq_sb35Y_N0lMxv2o,105
2
+ hubble/__main__.py,sha256=XjL0Sd1rAHrHv3_FiRTDA-Z5iM5BQPLeIPnvenhf9oI,68
3
+ hubble/agent.py,sha256=oUbve6Er_UKsIv09Zyf3LxPrgVRUUsii7FD-_NbY8BA,25871
4
+ hubble/banner.py,sha256=wuyYk332xiz3e3jn54I1-qSxPIjzTxCbxmSemNae8Vk,19866
5
+ hubble/board.py,sha256=1SZ6EvP7pHIIZKVpnc6IsBIcaK5fKoxjNhQT2C6Q3_I,4628
6
+ hubble/main.py,sha256=JNX3ARUWHjed4Or94GNrRVv4_JVvY80A_iMNeDYoi_g,13647
7
+ hubble/models.py,sha256=o7DUgwupRyeRwio6KtDPhMF1Fcqz5alYbF7fo5wNIdQ,2860
8
+ hubble/onboarding.py,sha256=VZnRCuFR8izyVKndtd305QdFUC4FvsuEkj9STuY_D3A,3905
9
+ hubble/permissions.py,sha256=6IzHedsGrnCsyhGhET1BxEu7WhZxyEAluxk06DMzL3A,5948
10
+ hubble/prompts.py,sha256=aKQ_2Ty7z-Q9KGeq5DFO3aPOLpLxM1B5Qu2h-5atDvg,7817
11
+ hubble/provider.py,sha256=jGcCYlu0Fk_wj6K-sUlBY_a7wYqEJZM_mDw0g40WggI,11622
12
+ hubble/providers.py,sha256=OtY_8idOceYwB_E95AaX0qOhNM8o3R3yHmyr0L8AGHc,7878
13
+ hubble/repl.py,sha256=Cor3BKWITvFTRSIALP75lXZ_Hf0ne-6xdrhl0LObxqQ,47933
14
+ hubble/scanner.py,sha256=TRh5XX-qbq-YXO5tLBRWPoZ5cWFPlk_ryUhIfJ4HLvs,5751
15
+ hubble/session.py,sha256=n639MddGQK37Oo5BUNNIMwf6N8h5t_h52BiH_P46v_o,5184
16
+ hubble/settings.py,sha256=4IEYjXscviHkV4LqWRqO1h8Hf4orffW9U3Oh_u7fpD4,6873
17
+ hubble/skills.py,sha256=gQholahVCflzqk6CSnFVcF7agyHvzkwsZf_rIPTfzVc,7559
18
+ hubble/spinner.py,sha256=Wt_rmzhBn1SAYixy2kjVwju5IlSACuXAg0PNJZYTe8M,2780
19
+ hubble/tools.py,sha256=5VzK5hPFpIHQcqkjVWrTRREaNo53VOSZY6X9MNTl7uM,32844
20
+ hubble/ui.py,sha256=BR49USTzmI051XHBx50PsWGz72XKn6KUdOY2lstgIv0,18831
21
+ hubble/web.py,sha256=8RneXMH4NQAoQDBdcNsF6_QjeLr9U1ZQOXYuWa0t8Uc,12015
22
+ hubble_cli-4.0.0.dist-info/licenses/LICENSE,sha256=7V8iNeMhHCxShIZUJn-twzCxfvMCiYIpWUZqtuigrkk,1077
23
+ hubble_cli-4.0.0.dist-info/METADATA,sha256=CbxVtPi7bbzdC8XT2_1syaSVqHf1rfrfeRv2ualQOm4,13480
24
+ hubble_cli-4.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
25
+ hubble_cli-4.0.0.dist-info/entry_points.txt,sha256=kB02dm3At88fhIrS3_oZs7WYFtIbddHQTm1WHgJEKY8,69
26
+ hubble_cli-4.0.0.dist-info/top_level.txt,sha256=qRONSj2uSA-PR_NbvC3igK1Sy-yOB7obA4lxjpcZASY,7
27
+ hubble_cli-4.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ aihub = hubble.main:main
3
+ hubble = hubble.main:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hamday Rabby Hossain
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 @@
1
+ hubble