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/__init__.py +3 -0
- hubble/__main__.py +4 -0
- hubble/agent.py +546 -0
- hubble/banner.py +484 -0
- hubble/board.py +118 -0
- hubble/main.py +299 -0
- hubble/models.py +92 -0
- hubble/onboarding.py +94 -0
- hubble/permissions.py +139 -0
- hubble/prompts.py +157 -0
- hubble/provider.py +283 -0
- hubble/providers.py +186 -0
- hubble/repl.py +976 -0
- hubble/scanner.py +134 -0
- hubble/session.py +128 -0
- hubble/settings.py +184 -0
- hubble/skills.py +173 -0
- hubble/spinner.py +77 -0
- hubble/tools.py +763 -0
- hubble/ui.py +469 -0
- hubble/web.py +279 -0
- hubble_cli-4.0.0.dist-info/METADATA +275 -0
- hubble_cli-4.0.0.dist-info/RECORD +27 -0
- hubble_cli-4.0.0.dist-info/WHEEL +5 -0
- hubble_cli-4.0.0.dist-info/entry_points.txt +3 -0
- hubble_cli-4.0.0.dist-info/licenses/LICENSE +21 -0
- hubble_cli-4.0.0.dist-info/top_level.txt +1 -0
hubble/main.py
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""`hubble` entry point: interactive REPL, headless -p mode, session resume."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from hubble import __version__
|
|
9
|
+
from hubble.permissions import MODES, Permissions
|
|
10
|
+
from hubble.prompts import PERSONAS
|
|
11
|
+
from hubble.provider import OpenAICompatProvider
|
|
12
|
+
from hubble.session import SessionStore
|
|
13
|
+
from hubble.settings import ConfigError, load_settings, require_api_key, trust_folder
|
|
14
|
+
from hubble.tools import ToolContext, detect_shell
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
18
|
+
p = argparse.ArgumentParser(
|
|
19
|
+
prog="hubble", description="Hubble: agentic coding CLI for AIHub and other OpenAI-compatible APIs.",
|
|
20
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
21
|
+
epilog="""examples:
|
|
22
|
+
hubble start an interactive session
|
|
23
|
+
hubble "explain this repo" start a session with a first prompt
|
|
24
|
+
hubble -p "fix the failing test" --permission-mode accept-edits
|
|
25
|
+
git diff | hubble -p "review this diff" --output-format json
|
|
26
|
+
hubble -c continue the latest session here
|
|
27
|
+
hubble -r pick a session to resume
|
|
28
|
+
""")
|
|
29
|
+
p.add_argument("prompt", nargs="?", help="initial prompt")
|
|
30
|
+
p.add_argument("-p", "--print", dest="print_mode", action="store_true",
|
|
31
|
+
help="non-interactive: run the prompt, print the result and exit")
|
|
32
|
+
p.add_argument("-c", "--continue", dest="continue_", action="store_true", help="continue the latest session")
|
|
33
|
+
p.add_argument("-r", "--resume", nargs="?", const="", metavar="ID", help="resume a session (lists them without ID)")
|
|
34
|
+
p.add_argument("-m", "--model", help="model id")
|
|
35
|
+
p.add_argument("--provider", help="provider name from /provider (default: last used)")
|
|
36
|
+
p.add_argument("--permission-mode", choices=MODES, help="default | accept-edits | plan | yolo")
|
|
37
|
+
p.add_argument("--yolo", action="store_true", help="shortcut for --permission-mode yolo (no approvals)")
|
|
38
|
+
p.add_argument("--persona", choices=list(PERSONAS), help="system persona")
|
|
39
|
+
p.add_argument("--output-format", choices=["text", "json"], default="text", help="output format for -p")
|
|
40
|
+
p.add_argument("--max-turns", type=int, help="max model calls per prompt")
|
|
41
|
+
p.add_argument("--cwd", help="workspace root (default: current directory)")
|
|
42
|
+
p.add_argument("--base-url", help="API base URL")
|
|
43
|
+
p.add_argument("--api-key", help="API key (default: HUBBLE_API_KEY / .env)")
|
|
44
|
+
p.add_argument("--allow", action="append", default=[], metavar="RULE", help='allow rule, e.g. "shell(pytest*)"')
|
|
45
|
+
p.add_argument("--deny", action="append", default=[], metavar="RULE", help='deny rule, e.g. "shell(git push*)"')
|
|
46
|
+
p.add_argument("--no-session", action="store_true", help="do not save this session")
|
|
47
|
+
p.add_argument("--quiet", action="store_true", help="with -p: hide tool progress on stderr")
|
|
48
|
+
p.add_argument("--test", metavar="MODEL", help="check that a model responds, then exit")
|
|
49
|
+
p.add_argument("-v", "--version", action="version", version=f"hubble {__version__}")
|
|
50
|
+
return p
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def main(argv=None):
|
|
54
|
+
if sys.platform == "win32":
|
|
55
|
+
for stream in (sys.stdout, sys.stderr):
|
|
56
|
+
try:
|
|
57
|
+
stream.reconfigure(encoding="utf-8") # model output often contains emoji
|
|
58
|
+
except (AttributeError, ValueError):
|
|
59
|
+
pass
|
|
60
|
+
args = build_parser().parse_args(argv)
|
|
61
|
+
root = Path(args.cwd or ".").resolve()
|
|
62
|
+
if not root.is_dir():
|
|
63
|
+
sys.exit(f"hubble: workspace not found: {root}")
|
|
64
|
+
|
|
65
|
+
overrides = {
|
|
66
|
+
"model": args.model, "base_url": args.base_url, "api_key": args.api_key,
|
|
67
|
+
"persona": args.persona, "max_turns": args.max_turns,
|
|
68
|
+
"permission_mode": "yolo" if args.yolo else args.permission_mode,
|
|
69
|
+
}
|
|
70
|
+
interactive = not args.print_mode and sys.stdin.isatty()
|
|
71
|
+
try:
|
|
72
|
+
settings = load_settings(root, overrides)
|
|
73
|
+
ignored = settings["_ignored_project_keys"]
|
|
74
|
+
if ignored:
|
|
75
|
+
if _ask_trust(root, ignored, interactive=interactive):
|
|
76
|
+
trust_folder(root)
|
|
77
|
+
settings = load_settings(root, overrides)
|
|
78
|
+
except ConfigError as e:
|
|
79
|
+
sys.exit(f"hubble: {e}")
|
|
80
|
+
|
|
81
|
+
from hubble.providers import DEFAULT_PROVIDER, load_providers
|
|
82
|
+
providers = load_providers(settings)
|
|
83
|
+
active = args.provider or settings.get("provider") or DEFAULT_PROVIDER
|
|
84
|
+
if args.api_key or args.base_url:
|
|
85
|
+
active = DEFAULT_PROVIDER # explicit credentials on the command line win
|
|
86
|
+
if active not in providers:
|
|
87
|
+
if args.provider:
|
|
88
|
+
sys.exit(f"hubble: unknown provider '{args.provider}'. Known: {', '.join(providers) or 'none'}")
|
|
89
|
+
if providers:
|
|
90
|
+
active = DEFAULT_PROVIDER if DEFAULT_PROVIDER in providers else next(iter(providers))
|
|
91
|
+
elif interactive:
|
|
92
|
+
active = _first_run(providers, settings)
|
|
93
|
+
else:
|
|
94
|
+
try:
|
|
95
|
+
require_api_key(settings)
|
|
96
|
+
except ConfigError as e:
|
|
97
|
+
sys.exit(f"hubble: {e}")
|
|
98
|
+
cfg = providers[active]
|
|
99
|
+
settings["provider"] = active
|
|
100
|
+
provider = OpenAICompatProvider(cfg.base_url, cfg.api_key)
|
|
101
|
+
|
|
102
|
+
if args.test:
|
|
103
|
+
res = provider.ping(args.test)
|
|
104
|
+
print(f"{'OK' if res['ok'] else 'FAILED'} {args.test} {res['latency_ms']} ms {res['msg']}")
|
|
105
|
+
sys.exit(0 if res["ok"] else 1)
|
|
106
|
+
|
|
107
|
+
ctx = ToolContext(
|
|
108
|
+
root=root,
|
|
109
|
+
extra_dirs=[Path(d).expanduser().resolve() for d in settings.get("additional_dirs", [])],
|
|
110
|
+
allow_secrets=bool(settings.get("allow_secret_files")),
|
|
111
|
+
shell_argv=detect_shell(settings.get("shell", "auto")),
|
|
112
|
+
shell_timeout=int(settings.get("shell_timeout", 120)),
|
|
113
|
+
sandbox=settings.get("shell_sandbox", "off"),
|
|
114
|
+
sandbox_image=settings.get("sandbox_image", "python:3.12-slim"),
|
|
115
|
+
sandbox_memory=settings.get("sandbox_memory", "1g"),
|
|
116
|
+
sandbox_cpus=str(settings.get("sandbox_cpus", "2")),
|
|
117
|
+
sandbox_network=bool(settings.get("sandbox_network", True)),
|
|
118
|
+
)
|
|
119
|
+
perms = Permissions(settings["permission_mode"],
|
|
120
|
+
allow=settings["permissions"].get("allow", []) + args.allow,
|
|
121
|
+
deny=settings["permissions"].get("deny", []) + args.deny)
|
|
122
|
+
store = SessionStore(root)
|
|
123
|
+
|
|
124
|
+
prompt = args.prompt or ""
|
|
125
|
+
if args.resume and not any(args.resume in it["id"] for it in store.list(limit=500)):
|
|
126
|
+
# `hubble -r "fix the bug"`: argparse took the prompt as a session id.
|
|
127
|
+
prompt = f"{args.resume} {prompt}".strip()
|
|
128
|
+
args.resume = ""
|
|
129
|
+
if args.print_mode and not sys.stdin.isatty():
|
|
130
|
+
piped = sys.stdin.read()
|
|
131
|
+
if piped.strip():
|
|
132
|
+
prompt = f"{prompt}\n\n<stdin>\n{piped}\n</stdin>" if prompt else piped
|
|
133
|
+
|
|
134
|
+
if args.print_mode:
|
|
135
|
+
args._providers = providers
|
|
136
|
+
sys.exit(run_headless(args, settings, provider, ctx, perms, store, prompt,
|
|
137
|
+
_fallback_client(providers, active, provider)))
|
|
138
|
+
|
|
139
|
+
from hubble.agent import Agent
|
|
140
|
+
try:
|
|
141
|
+
from hubble.repl import Repl
|
|
142
|
+
from hubble.ui import ReplEvents, console
|
|
143
|
+
except ImportError as e:
|
|
144
|
+
sys.exit(f"hubble: missing dependency ({e.name}). Install with:\n"
|
|
145
|
+
f' "{sys.executable}" -m pip install -r "{Path(__file__).resolve().parent.parent / "requirements.txt"}"')
|
|
146
|
+
|
|
147
|
+
agent = Agent(provider, settings, ctx, perms, ReplEvents(ctx, show_reasoning=bool(settings.get("show_reasoning"))))
|
|
148
|
+
agent.fallback_client = _fallback_client(providers, active, provider)
|
|
149
|
+
repl = Repl(agent, store, providers)
|
|
150
|
+
resumed = _resume(args, store, agent, console)
|
|
151
|
+
if resumed is None:
|
|
152
|
+
return
|
|
153
|
+
if not resumed and not args.no_session:
|
|
154
|
+
agent.session = store.new(agent.model)
|
|
155
|
+
repl.run(initial_prompt=prompt or None)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _headless_resolver(settings, providers, agent):
|
|
159
|
+
from hubble.providers import resolve_fallback
|
|
160
|
+
clients = {agent.provider_name: agent.provider}
|
|
161
|
+
|
|
162
|
+
def resolve(provider_name, model):
|
|
163
|
+
choice = resolve_fallback(settings, providers, provider_name, model)
|
|
164
|
+
if not choice:
|
|
165
|
+
return None
|
|
166
|
+
name, fb = choice
|
|
167
|
+
if name not in clients:
|
|
168
|
+
if name not in providers:
|
|
169
|
+
return None
|
|
170
|
+
clients[name] = OpenAICompatProvider(providers[name].base_url, providers[name].api_key)
|
|
171
|
+
return clients[name], fb
|
|
172
|
+
return resolve
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _fallback_client(providers, active, current):
|
|
176
|
+
"""fallback_model lives on the built-in hubble provider."""
|
|
177
|
+
from hubble.providers import DEFAULT_PROVIDER
|
|
178
|
+
if active == DEFAULT_PROVIDER:
|
|
179
|
+
return current
|
|
180
|
+
cfg = providers.get(DEFAULT_PROVIDER)
|
|
181
|
+
return OpenAICompatProvider(cfg.base_url, cfg.api_key) if cfg else None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _first_run(providers, settings) -> str:
|
|
185
|
+
from hubble.onboarding import add_provider_wizard
|
|
186
|
+
from hubble.repl import register_provider
|
|
187
|
+
from hubble.settings import save_user_setting
|
|
188
|
+
from hubble.ui import console, pick
|
|
189
|
+
|
|
190
|
+
result = add_provider_wizard(providers.keys(), first_run=True)
|
|
191
|
+
if not result:
|
|
192
|
+
sys.exit("hubble: no provider configured.")
|
|
193
|
+
cfg, ids = result
|
|
194
|
+
register_provider(cfg, ids)
|
|
195
|
+
providers[cfg.name] = cfg
|
|
196
|
+
model = pick(f"Choose a model ({len(ids)} listed)", [(m, m, "") for m in sorted(ids)]) or sorted(ids)[0]
|
|
197
|
+
settings["model"] = model
|
|
198
|
+
save_user_setting("provider", cfg.name)
|
|
199
|
+
save_user_setting("model", model)
|
|
200
|
+
console.print(f"[green]Saved. Using {cfg.name}: {model}[/green]")
|
|
201
|
+
return cfg.name
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _ask_trust(root: Path, ignored, interactive: bool) -> bool:
|
|
205
|
+
never = [k for k in ignored if k in ("base_url", "api_key")]
|
|
206
|
+
if never:
|
|
207
|
+
print(f"hubble: ignoring {', '.join(never)} from {root / '.hubble'} (only user settings or env may set them)",
|
|
208
|
+
file=sys.stderr)
|
|
209
|
+
rest = [k for k in ignored if k not in ("base_url", "api_key")]
|
|
210
|
+
if not rest:
|
|
211
|
+
return False
|
|
212
|
+
if not interactive:
|
|
213
|
+
print(f"hubble: ignoring untrusted project settings: {', '.join(rest)}. Run hubble interactively once "
|
|
214
|
+
"in this folder to trust it.", file=sys.stderr)
|
|
215
|
+
return False
|
|
216
|
+
print(f"This folder's .hubble settings want to change security options: {', '.join(rest)}.")
|
|
217
|
+
try:
|
|
218
|
+
answer = input(f"Trust {root} and apply them? [y/N] ").strip().lower()
|
|
219
|
+
except (EOFError, KeyboardInterrupt):
|
|
220
|
+
return False
|
|
221
|
+
return answer in ("y", "yes")
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _resume(args, store, agent, console):
|
|
225
|
+
"""True if a session was loaded, False to start fresh, None to exit."""
|
|
226
|
+
if args.resume == "" and not args.continue_:
|
|
227
|
+
from hubble.repl import session_items
|
|
228
|
+
from hubble.ui import pick
|
|
229
|
+
items = store.list()
|
|
230
|
+
if not items:
|
|
231
|
+
console.print("[dim]No sessions to resume; starting a new one.[/dim]")
|
|
232
|
+
return False
|
|
233
|
+
sid = pick("Resume a session (Esc for a new one)", session_items(items))
|
|
234
|
+
if not sid:
|
|
235
|
+
return False
|
|
236
|
+
elif args.resume or args.continue_:
|
|
237
|
+
sid = args.resume or store.latest()
|
|
238
|
+
if not sid:
|
|
239
|
+
console.print("[dim]No previous session here; starting a new one.[/dim]")
|
|
240
|
+
return False
|
|
241
|
+
else:
|
|
242
|
+
return False
|
|
243
|
+
try:
|
|
244
|
+
session, messages, meta = store.load(sid)
|
|
245
|
+
except FileNotFoundError as e:
|
|
246
|
+
console.print(f"[red]{e}[/red]")
|
|
247
|
+
return None
|
|
248
|
+
agent.session = session
|
|
249
|
+
agent.load_history(messages)
|
|
250
|
+
# Keep the model only if the session used the provider that is active now.
|
|
251
|
+
from hubble.providers import normalize_provider_name
|
|
252
|
+
if meta.get("model") and not args.model and normalize_provider_name(meta.get("provider")) == agent.provider_name:
|
|
253
|
+
agent.model = meta["model"]
|
|
254
|
+
console.print(f"[green]Resumed session {session.id}[/green] [dim]({len(messages)} messages)[/dim]")
|
|
255
|
+
from hubble.repl import print_history_tail
|
|
256
|
+
print_history_tail(messages)
|
|
257
|
+
return True
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def run_headless(args, settings, provider, ctx, perms, store, prompt, fallback_client=None) -> int:
|
|
261
|
+
from hubble.agent import Agent
|
|
262
|
+
from hubble.ui import HeadlessEvents
|
|
263
|
+
|
|
264
|
+
if not prompt.strip():
|
|
265
|
+
print("hubble: -p needs a prompt (argument or stdin)", file=sys.stderr)
|
|
266
|
+
return 2
|
|
267
|
+
events = HeadlessEvents(stream_text=args.output_format == "text", quiet=args.quiet)
|
|
268
|
+
agent = Agent(provider, settings, ctx, perms, events)
|
|
269
|
+
agent.fallback_client = fallback_client
|
|
270
|
+
agent.fallback_resolver = _headless_resolver(settings, getattr(args, "_providers", {}), agent)
|
|
271
|
+
if args.continue_ or args.resume:
|
|
272
|
+
sid = args.resume or store.latest()
|
|
273
|
+
if sid:
|
|
274
|
+
try:
|
|
275
|
+
session, messages, _ = store.load(sid)
|
|
276
|
+
agent.session = session
|
|
277
|
+
agent.load_history(messages)
|
|
278
|
+
except FileNotFoundError as e:
|
|
279
|
+
print(f"hubble: {e}", file=sys.stderr)
|
|
280
|
+
return 2
|
|
281
|
+
if agent.session is None and not args.no_session:
|
|
282
|
+
agent.session = store.new(agent.model)
|
|
283
|
+
|
|
284
|
+
result = agent.run(prompt)
|
|
285
|
+
stats = agent.last_stats
|
|
286
|
+
is_error = bool(stats.error) or stats.interrupted
|
|
287
|
+
if args.output_format == "json":
|
|
288
|
+
print(json.dumps({
|
|
289
|
+
"result": result, "is_error": is_error, "error": stats.error,
|
|
290
|
+
"session_id": agent.session.id if agent.session else None, "model": agent.model,
|
|
291
|
+
"num_model_calls": stats.model_calls, "num_tool_calls": stats.tool_calls,
|
|
292
|
+
"usage": {"prompt_tokens": stats.prompt_tokens, "completion_tokens": stats.completion_tokens},
|
|
293
|
+
"duration_s": round(stats.duration, 2),
|
|
294
|
+
}, ensure_ascii=False, indent=2))
|
|
295
|
+
return 1 if is_error else 0
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
if __name__ == "__main__":
|
|
299
|
+
main()
|
hubble/models.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Model registry: curated categories plus the latest scan from test_models.py."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Dict, List
|
|
6
|
+
|
|
7
|
+
PROJECT_DIR = Path(__file__).resolve().parent.parent
|
|
8
|
+
SCAN_FILE = PROJECT_DIR / "available_models.json"
|
|
9
|
+
|
|
10
|
+
DEFAULT_MODEL = "codestral-latest"
|
|
11
|
+
|
|
12
|
+
MODEL_CATEGORIES = {
|
|
13
|
+
"Coding Specialists": [
|
|
14
|
+
"codestral-latest",
|
|
15
|
+
"codestral-2508",
|
|
16
|
+
"mistral-code-latest",
|
|
17
|
+
"mistral-code-fim-latest",
|
|
18
|
+
],
|
|
19
|
+
"Reasoning & Logic": [
|
|
20
|
+
"nvidia/nemotron-3-super-120b-a12b",
|
|
21
|
+
"intern-s2-preview",
|
|
22
|
+
"intern-s1-mini",
|
|
23
|
+
"intern-s1",
|
|
24
|
+
"intern-s1-pro",
|
|
25
|
+
],
|
|
26
|
+
"Fast Chat & General": [
|
|
27
|
+
"ministral-14b-latest",
|
|
28
|
+
"open-mistral-nemo",
|
|
29
|
+
"ministral-8b-latest",
|
|
30
|
+
"ministral-3b-latest",
|
|
31
|
+
"mistral-tiny-latest",
|
|
32
|
+
"qwen-8b",
|
|
33
|
+
],
|
|
34
|
+
"Multimodal & Vision": [
|
|
35
|
+
"meta/llama-3.2-11b-vision-instruct",
|
|
36
|
+
"internvl3.5-latest",
|
|
37
|
+
"internvl-latest",
|
|
38
|
+
],
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
RELIABLE_MODELS = [
|
|
42
|
+
"codestral-latest",
|
|
43
|
+
"codestral-2508",
|
|
44
|
+
"mistral-code-latest",
|
|
45
|
+
"mistral-code-fim-latest",
|
|
46
|
+
"nvidia/nemotron-3-super-120b-a12b",
|
|
47
|
+
"ministral-14b-latest",
|
|
48
|
+
"open-mistral-nemo",
|
|
49
|
+
"ministral-8b-latest",
|
|
50
|
+
"intern-s2-preview",
|
|
51
|
+
"intern-s1-mini",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load_scanned_models(path: Path = SCAN_FILE) -> List[Dict[str, Any]]:
|
|
56
|
+
"""Working models found by the last `python test_models.py` run."""
|
|
57
|
+
try:
|
|
58
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
59
|
+
return json.load(f).get("working_models", [])
|
|
60
|
+
except (OSError, ValueError):
|
|
61
|
+
return []
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def all_models() -> List[Dict[str, Any]]:
|
|
65
|
+
"""Curated models first, then any extra scanned ones.
|
|
66
|
+
|
|
67
|
+
Each entry has model, category, latency_ms and available (None when there is no scan yet).
|
|
68
|
+
"""
|
|
69
|
+
scanned = {m["model"]: m for m in load_scanned_models() if m.get("model")}
|
|
70
|
+
have_scan = bool(scanned)
|
|
71
|
+
out: List[Dict[str, Any]] = []
|
|
72
|
+
seen = set()
|
|
73
|
+
for category, names in MODEL_CATEGORIES.items():
|
|
74
|
+
for name in names:
|
|
75
|
+
out.append({"model": name, "category": category,
|
|
76
|
+
"latency_ms": scanned.get(name, {}).get("latency_ms"),
|
|
77
|
+
"available": (name in scanned) if have_scan else None})
|
|
78
|
+
seen.add(name)
|
|
79
|
+
for name, info in scanned.items():
|
|
80
|
+
if name not in seen:
|
|
81
|
+
out.append({"model": name, "category": "Other Verified",
|
|
82
|
+
"latency_ms": info.get("latency_ms"), "available": True})
|
|
83
|
+
return out
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def resolve_model(query: str, candidates: List[str]) -> List[str]:
|
|
87
|
+
"""Exact match wins, otherwise case-insensitive substring matches."""
|
|
88
|
+
q = query.strip().lower()
|
|
89
|
+
exact = [m for m in candidates if m.lower() == q]
|
|
90
|
+
if exact:
|
|
91
|
+
return exact
|
|
92
|
+
return [m for m in candidates if q in m.lower()]
|
hubble/onboarding.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Interactive wizard for adding a provider (base URL + API key), with verification."""
|
|
2
|
+
|
|
3
|
+
from typing import Iterable, List, Optional, Tuple
|
|
4
|
+
from urllib.parse import urlparse
|
|
5
|
+
|
|
6
|
+
from rich.markup import escape
|
|
7
|
+
|
|
8
|
+
from hubble.provider import normalize_base_url
|
|
9
|
+
from hubble.providers import KNOWN_EXAMPLES, NAME_RX, ProviderConfig, verify
|
|
10
|
+
from hubble.ui import console, pick
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _ask(message: str, default: str = "", password: bool = False) -> Optional[str]:
|
|
14
|
+
from prompt_toolkit import prompt
|
|
15
|
+
try:
|
|
16
|
+
return prompt(message, default=default, is_password=password).strip()
|
|
17
|
+
except (EOFError, KeyboardInterrupt):
|
|
18
|
+
return None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _confirm(message: str, default: bool = True) -> bool:
|
|
22
|
+
hint = "[Y/n]" if default else "[y/N]"
|
|
23
|
+
answer = _ask(f"{message} {hint} ")
|
|
24
|
+
if answer is None:
|
|
25
|
+
return False
|
|
26
|
+
return default if not answer else answer.lower() in ("y", "yes")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _suggest_name(url: str, taken: Iterable[str]) -> str:
|
|
30
|
+
host = urlparse(url).hostname or "provider"
|
|
31
|
+
parts = [p for p in host.split(".") if p not in ("www", "api", "com", "ai", "io", "xyz", "net", "org", "dev")]
|
|
32
|
+
base = (parts[0] if parts else "provider").lower()
|
|
33
|
+
base = "".join(ch for ch in base if ch.isalnum() or ch in "-_")[:24] or "provider"
|
|
34
|
+
name, n = base, 2
|
|
35
|
+
while name in taken:
|
|
36
|
+
name, n = f"{base}{n}", n + 1
|
|
37
|
+
return name
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def add_provider_wizard(taken: Iterable[str], first_run: bool = False) -> Optional[Tuple[ProviderConfig, List[str]]]:
|
|
41
|
+
"""Returns (config, model_ids) after a successful check, or None if cancelled."""
|
|
42
|
+
taken = set(taken)
|
|
43
|
+
if first_run:
|
|
44
|
+
console.print("[bold cyan]✦ Welcome to Hubble[/bold cyan]\n"
|
|
45
|
+
" No API key is configured yet. Add any OpenAI-compatible provider to start.\n")
|
|
46
|
+
else:
|
|
47
|
+
console.print("[bold]Add a provider[/bold] [dim](any OpenAI-compatible API; Esc/Ctrl+C cancels)[/dim]")
|
|
48
|
+
|
|
49
|
+
choice = pick("Provider", [(url, name, url) for name, url in KNOWN_EXAMPLES] +
|
|
50
|
+
[("custom", "Custom URL...", "enter any OpenAI-compatible base URL")])
|
|
51
|
+
if choice is None:
|
|
52
|
+
return None
|
|
53
|
+
default_url = "" if choice == "custom" else choice
|
|
54
|
+
|
|
55
|
+
while True:
|
|
56
|
+
raw_url = _ask("Base URL: ", default=default_url)
|
|
57
|
+
if not raw_url:
|
|
58
|
+
return None
|
|
59
|
+
url = normalize_base_url(raw_url)
|
|
60
|
+
local = urlparse(url).hostname in ("localhost", "127.0.0.1", "::1")
|
|
61
|
+
key = _ask("API key" + (" (optional for local servers)" if local else "") + ": ", password=True)
|
|
62
|
+
if key is None:
|
|
63
|
+
return None
|
|
64
|
+
key = key or ("none" if local else "")
|
|
65
|
+
if not key:
|
|
66
|
+
console.print("[red]An API key is required.[/red]")
|
|
67
|
+
continue
|
|
68
|
+
|
|
69
|
+
with console.status(f"[dim]Checking {escape(url)} ...[/dim]"):
|
|
70
|
+
ok, message, ids = verify(url, key)
|
|
71
|
+
if ok:
|
|
72
|
+
console.print(f"[green]✔ {escape(message)}[/green]")
|
|
73
|
+
break
|
|
74
|
+
console.print(f"[red]✘ {escape(message)}[/red]")
|
|
75
|
+
if not _confirm("Try again?"):
|
|
76
|
+
return None
|
|
77
|
+
default_url = raw_url
|
|
78
|
+
|
|
79
|
+
while True:
|
|
80
|
+
name = _ask("Name for this provider: ", default=_suggest_name(url, taken))
|
|
81
|
+
if name is None:
|
|
82
|
+
return None
|
|
83
|
+
name = name.lower()
|
|
84
|
+
if not NAME_RX.match(name):
|
|
85
|
+
console.print("[red]Use lowercase letters, digits, - or _ (max 31 chars).[/red]")
|
|
86
|
+
elif name in taken:
|
|
87
|
+
console.print(f"[red]'{escape(name)}' already exists.[/red]")
|
|
88
|
+
else:
|
|
89
|
+
break
|
|
90
|
+
|
|
91
|
+
console.print(f"[dim] Checking availability sends one tiny request to each of the {len(ids)} models. "
|
|
92
|
+
"That is free on most gateways but can cost a little on paid APIs.[/dim]")
|
|
93
|
+
check = _confirm(f"Check which of the {len(ids)} models respond now (runs in the background)?")
|
|
94
|
+
return ProviderConfig(name, url, key, check_models=check), ids
|
hubble/permissions.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Permission modes and allow/deny rules.
|
|
2
|
+
|
|
3
|
+
Rule syntax: `tool` or `tool(pattern)`, e.g. `shell(git status*)`, `edit_file(src/*)`,
|
|
4
|
+
`read_file(secrets/*)`. Patterns use fnmatch against the tool's target (a workspace-relative
|
|
5
|
+
path, or a command). Deny rules win, then allow rules, then the mode default.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import fnmatch
|
|
9
|
+
import re
|
|
10
|
+
from typing import List, Optional, Tuple
|
|
11
|
+
|
|
12
|
+
MODES = ["default", "accept-edits", "plan", "yolo"]
|
|
13
|
+
CYCLE_MODES = ["default", "accept-edits", "plan"] # yolo only by explicit choice
|
|
14
|
+
MODE_HELP = {
|
|
15
|
+
"default": "ask before edits and shell commands",
|
|
16
|
+
"accept-edits": "auto-approve file edits, ask before shell commands",
|
|
17
|
+
"plan": "read-only: explore and propose a plan, no edits or commands",
|
|
18
|
+
"yolo": "auto-approve everything except deny rules",
|
|
19
|
+
}
|
|
20
|
+
ALIASES = {"bash": "shell", "run": "shell", "edit": "edit_file", "write": "write_file",
|
|
21
|
+
"read": "read_file", "ls": "list_dir", "find": "glob", "todo": "todo_write"}
|
|
22
|
+
# A command may ride on a pattern allow rule only if it is one plain program invocation:
|
|
23
|
+
# no separators, subexpressions, redirects, variables or quotes in any shell (cmd, PowerShell, bash).
|
|
24
|
+
SAFE_COMMAND = re.compile(r"^[\w ./:=+,@\-]+$")
|
|
25
|
+
# Split points used to apply deny rules to every part of a chained command.
|
|
26
|
+
SEPARATORS = re.compile(r"&&|\|\||[;|&\r\n()`{}]|\$\(")
|
|
27
|
+
MULTI_WORD_LAUNCHERS = {"python", "python3", "py", "node", "npx", "uv", "uvx", "poetry", "pipx", "dotnet", "go",
|
|
28
|
+
"cargo", "npm", "pnpm", "yarn", "git", "docker", "kubectl", "pip", "make"}
|
|
29
|
+
|
|
30
|
+
RULE_RX = re.compile(r"^\s*([\w-]+)\s*(?:\((.*)\))?\s*$")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def parse_rule(rule: str) -> Optional[Tuple[str, Optional[str]]]:
|
|
34
|
+
m = RULE_RX.match(rule)
|
|
35
|
+
if not m:
|
|
36
|
+
return None
|
|
37
|
+
name = m.group(1).lower()
|
|
38
|
+
return ALIASES.get(name, name), m.group(2)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def rule_matches(rule: str, tool: str, target: str) -> bool:
|
|
42
|
+
parsed = parse_rule(rule)
|
|
43
|
+
if not parsed:
|
|
44
|
+
return False
|
|
45
|
+
name, pattern = parsed
|
|
46
|
+
if name not in (tool, "*"):
|
|
47
|
+
return False
|
|
48
|
+
if pattern is None or pattern == "*":
|
|
49
|
+
return True
|
|
50
|
+
norm = target.replace("\\", "/").strip()
|
|
51
|
+
return fnmatch.fnmatchcase(norm, pattern) or fnmatch.fnmatchcase(norm.lower(), pattern.lower())
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def command_prefix(command: str) -> str:
|
|
55
|
+
"""`python -m pytest -q tests` -> `python -m pytest`; `git status -s` -> `git status`; `pytest -q` -> `pytest`."""
|
|
56
|
+
words = command.strip().split()
|
|
57
|
+
if not words:
|
|
58
|
+
return ""
|
|
59
|
+
prefix = [words[0]]
|
|
60
|
+
exe = words[0].lower().removesuffix(".exe")
|
|
61
|
+
if exe in MULTI_WORD_LAUNCHERS:
|
|
62
|
+
rest = words[1:]
|
|
63
|
+
if rest[:1] == ["-m"] and len(rest) > 1:
|
|
64
|
+
prefix += rest[:2]
|
|
65
|
+
elif rest and re.match(r"^[a-z][\w:-]*$", rest[0]):
|
|
66
|
+
prefix.append(rest[0])
|
|
67
|
+
if exe in ("npm", "pnpm", "yarn") and rest[0] == "run" and len(rest) > 1:
|
|
68
|
+
prefix.append(rest[1])
|
|
69
|
+
return " ".join(prefix)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Permissions:
|
|
73
|
+
def __init__(self, mode: str = "default", allow: Optional[List[str]] = None,
|
|
74
|
+
deny: Optional[List[str]] = None):
|
|
75
|
+
self.mode = mode if mode in MODES else "default"
|
|
76
|
+
self.allow = list(allow or [])
|
|
77
|
+
self.deny = list(deny or [])
|
|
78
|
+
self.session_allow: List[str] = []
|
|
79
|
+
|
|
80
|
+
def cycle_mode(self) -> str:
|
|
81
|
+
idx = CYCLE_MODES.index(self.mode) if self.mode in CYCLE_MODES else -1
|
|
82
|
+
self.mode = CYCLE_MODES[(idx + 1) % len(CYCLE_MODES)]
|
|
83
|
+
return self.mode
|
|
84
|
+
|
|
85
|
+
def _denied(self, tool: str, kind: str, target: str) -> Optional[str]:
|
|
86
|
+
targets = [target]
|
|
87
|
+
if kind == "exec":
|
|
88
|
+
targets += [part.strip() for part in SEPARATORS.split(target) if part.strip()]
|
|
89
|
+
for rule in self.deny:
|
|
90
|
+
if any(rule_matches(rule, tool, t) for t in targets):
|
|
91
|
+
return rule
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
def check(self, tool: str, kind: str, target: str) -> Tuple[str, str]:
|
|
95
|
+
"""Returns (decision, reason) where decision is allow | ask | deny."""
|
|
96
|
+
rule = self._denied(tool, kind, target)
|
|
97
|
+
if rule:
|
|
98
|
+
return "deny", f"blocked by deny rule {rule}"
|
|
99
|
+
if kind == "read":
|
|
100
|
+
return "allow", ""
|
|
101
|
+
if self.mode == "plan" and kind != "web": # fetching docs is fine while planning
|
|
102
|
+
return "deny", ("plan mode is read-only. Finish exploring, then present your plan; "
|
|
103
|
+
"the user will switch modes to implement it")
|
|
104
|
+
plain = kind != "exec" or bool(SAFE_COMMAND.match(target.strip()))
|
|
105
|
+
for rule in self.allow + self.session_allow:
|
|
106
|
+
if rule_matches(rule, tool, target):
|
|
107
|
+
parsed = parse_rule(rule)
|
|
108
|
+
if not plain and parsed and parsed[1] not in (None, "*"):
|
|
109
|
+
continue
|
|
110
|
+
return "allow", f"allowed by rule {rule}"
|
|
111
|
+
if self.mode == "yolo":
|
|
112
|
+
return "allow", "yolo mode"
|
|
113
|
+
if kind == "edit" and self.mode == "accept-edits":
|
|
114
|
+
return "allow", "accept-edits mode"
|
|
115
|
+
return "ask", ""
|
|
116
|
+
|
|
117
|
+
def _add(self, rule: str):
|
|
118
|
+
if rule not in self.session_allow:
|
|
119
|
+
self.session_allow.append(rule)
|
|
120
|
+
|
|
121
|
+
def always_rule(self, tool: str, kind: str, target: str) -> str:
|
|
122
|
+
"""Session rule(s) added when the user answers 'always'. Returns a description."""
|
|
123
|
+
if kind == "exec":
|
|
124
|
+
prefix = command_prefix(target)
|
|
125
|
+
if not prefix or not SAFE_COMMAND.match(target.strip()):
|
|
126
|
+
self._add(f"{tool}({target.strip()})")
|
|
127
|
+
return f"{tool}({target.strip()})"
|
|
128
|
+
self._add(f"{tool}({prefix})")
|
|
129
|
+
self._add(f"{tool}({prefix} *)")
|
|
130
|
+
return f"{tool}({prefix} *)"
|
|
131
|
+
if kind == "web":
|
|
132
|
+
self._add(f"{tool}({target})")
|
|
133
|
+
return f"{tool}({target})"
|
|
134
|
+
if kind == "edit":
|
|
135
|
+
self._add("edit_file")
|
|
136
|
+
self._add("write_file")
|
|
137
|
+
return "edit_file, write_file"
|
|
138
|
+
self._add(tool)
|
|
139
|
+
return tool
|