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/scanner.py ADDED
@@ -0,0 +1,134 @@
1
+ """Background model health scan: fetch /models, send a tiny completion to each, save working ones.
2
+
3
+ Writes available_models.json in the same format as test_models.py, so both stay compatible.
4
+ """
5
+
6
+ import json
7
+ import threading
8
+ import time
9
+ from concurrent.futures import ThreadPoolExecutor
10
+ from pathlib import Path
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ import httpx
14
+
15
+ from hubble.models import SCAN_FILE
16
+ from hubble.provider import normalize_base_url
17
+
18
+
19
+ def scan_age_hours(path: Path = SCAN_FILE) -> Optional[float]:
20
+ try:
21
+ return (time.time() - path.stat().st_mtime) / 3600
22
+ except OSError:
23
+ return None
24
+
25
+
26
+ def _probe(client: httpx.Client, base_url: str, headers: Dict[str, str], model: str, timeout: float) -> Dict[str, Any]:
27
+ payload = {"model": model, "messages": [{"role": "user", "content": "hi"}], "max_tokens": 10, "temperature": 0.1}
28
+ start = time.time()
29
+ result = {"model": model, "available": False, "status_code": None, "latency_ms": 0, "sample": "", "reason": ""}
30
+ try:
31
+ resp = client.post(f"{base_url}/chat/completions", headers=headers, json=payload, timeout=timeout)
32
+ result["latency_ms"] = round((time.time() - start) * 1000)
33
+ result["status_code"] = resp.status_code
34
+ text = resp.text
35
+ if resp.status_code != 200:
36
+ result["reason"] = f"HTTP {resp.status_code}"
37
+ elif "upstream returned 403" in text or '"upstream_error"' in text or "unhandled err" in text:
38
+ result["reason"] = "upstream error inside 200"
39
+ else:
40
+ choices = resp.json().get("choices") or []
41
+ content = ((choices[0].get("message") or {}).get("content") or "").strip() if choices else ""
42
+ if content:
43
+ result.update(available=True, reason="OK", sample=content.replace("\n", " ")[:60])
44
+ else:
45
+ result["reason"] = "empty content"
46
+ except (httpx.HTTPError, ValueError) as e:
47
+ result["latency_ms"] = round((time.time() - start) * 1000)
48
+ result["reason"] = f"{type(e).__name__}"
49
+ return result
50
+
51
+
52
+ class ModelScanner:
53
+ """Runs one scan at a time in a daemon thread; `status` is safe to read from the UI."""
54
+
55
+ def __init__(self, base_url: str, api_key: str, concurrency: int = 12, timeout: float = 12.0,
56
+ output: Path = SCAN_FILE):
57
+ self.base_url = normalize_base_url(base_url)
58
+ self.api_key = api_key
59
+ self.concurrency = concurrency
60
+ self.timeout = timeout
61
+ self.output = output
62
+ self.status = "idle" # idle | running | done | failed
63
+ self.done = 0
64
+ self.total = 0
65
+ self.working = 0
66
+ self.error = ""
67
+ self._thread: Optional[threading.Thread] = None
68
+
69
+ @property
70
+ def running(self) -> bool:
71
+ return self._thread is not None and self._thread.is_alive()
72
+
73
+ def start(self) -> bool:
74
+ if self.running:
75
+ return False
76
+ self.status, self.done, self.total, self.working, self.error = "running", 0, 0, 0, ""
77
+ self._thread = threading.Thread(target=self._run, name="hubble-model-scan", daemon=True)
78
+ self._thread.start()
79
+ return True
80
+
81
+ def summary(self) -> str:
82
+ if self.status == "running":
83
+ return f"scanning models {self.done}/{self.total or '?'}"
84
+ if self.status == "done":
85
+ return f"{self.working} models available"
86
+ if self.status == "failed":
87
+ return "model scan failed"
88
+ return ""
89
+
90
+ def _run(self):
91
+ headers = {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}
92
+ try:
93
+ with httpx.Client(timeout=self.timeout + 5) as client:
94
+ resp = client.get(f"{self.base_url}/models", headers=headers)
95
+ resp.raise_for_status()
96
+ data = resp.json()
97
+ entries = data.get("data", data) if isinstance(data, dict) else data
98
+ owners = {m["id"]: m.get("owned_by", "") for m in entries if isinstance(m, dict) and m.get("id")}
99
+ self.total = len(owners)
100
+ results: List[Dict[str, Any]] = []
101
+
102
+ def task(name):
103
+ r = _probe(client, self.base_url, headers, name, self.timeout)
104
+ self.done += 1
105
+ if r["available"]:
106
+ self.working += 1
107
+ return r
108
+
109
+ with ThreadPoolExecutor(max_workers=self.concurrency) as pool:
110
+ results = list(pool.map(task, owners))
111
+ except (httpx.HTTPError, ValueError, KeyError, TypeError) as e:
112
+ self.status, self.error = "failed", f"{type(e).__name__}: {e}"[:200]
113
+ return
114
+
115
+ working = sorted((r for r in results if r["available"]), key=lambda r: r["latency_ms"])
116
+ if not working:
117
+ # A gateway outage or bad key would otherwise wipe the model list.
118
+ self.status, self.error = "failed", "no model responded; kept the previous list"
119
+ return
120
+ save = {
121
+ "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
122
+ "base_url": self.base_url,
123
+ "total_tested": len(results),
124
+ "all_ids": list(owners),
125
+ "working_count": len(working),
126
+ "working_models": [{"model": r["model"], "latency_ms": r["latency_ms"],
127
+ "owner": owners.get(r["model"], ""), "sample": r["sample"]} for r in working],
128
+ "all_results": results,
129
+ }
130
+ self.output.parent.mkdir(parents=True, exist_ok=True)
131
+ tmp = self.output.with_suffix(".json.tmp")
132
+ tmp.write_text(json.dumps(save, indent=2, ensure_ascii=False), encoding="utf-8")
133
+ tmp.replace(self.output)
134
+ self.status = "done"
hubble/session.py ADDED
@@ -0,0 +1,128 @@
1
+ """Append-only JSONL session transcripts under ~/.hubble/projects/<project>/<id>.jsonl.
2
+
3
+ Record types: meta (first line), msg (one chat message), reset (history replaced by compaction).
4
+ """
5
+
6
+ import hashlib
7
+ import json
8
+ import re
9
+ import time
10
+ import uuid
11
+ from pathlib import Path
12
+ from typing import Any, Dict, List, Optional, Tuple
13
+
14
+ from hubble.settings import HOME_DIR
15
+
16
+
17
+ def project_dir(root: Path) -> Path:
18
+ slug = re.sub(r"[^A-Za-z0-9]+", "-", root.name).strip("-")[:40] or "root"
19
+ digest = hashlib.sha1(str(root).lower().encode()).hexdigest()[:8]
20
+ return HOME_DIR / "projects" / f"{slug}-{digest}"
21
+
22
+
23
+ class Session:
24
+ def __init__(self, path: Path, session_id: str):
25
+ self.path = path
26
+ self.id = session_id
27
+
28
+ def _write(self, record: Dict[str, Any]):
29
+ self.path.parent.mkdir(parents=True, exist_ok=True)
30
+ record.setdefault("ts", time.time())
31
+ line = json.dumps(record, ensure_ascii=False) + "\n"
32
+ if self.path.exists() and self.path.stat().st_size:
33
+ with open(self.path, "rb") as f:
34
+ f.seek(-1, 2)
35
+ if f.read(1) != b"\n":
36
+ line = "\n" + line # previous write was cut off; start a fresh line
37
+ with open(self.path, "a", encoding="utf-8") as f:
38
+ f.write(line)
39
+
40
+ def append(self, message: Dict[str, Any]):
41
+ self._write({"type": "msg", "message": message})
42
+
43
+ def reset(self, messages: List[Dict[str, Any]]):
44
+ self._write({"type": "reset", "messages": messages})
45
+
46
+ def meta(self, **fields):
47
+ self._write({"type": "meta", **fields})
48
+
49
+
50
+ class SessionStore:
51
+ def __init__(self, root: Path):
52
+ self.root = root
53
+ self.dir = project_dir(root)
54
+
55
+ def new(self, model: str) -> Session:
56
+ sid = time.strftime("%Y%m%d-%H%M%S") + "-" + uuid.uuid4().hex[:6]
57
+ session = Session(self.dir / f"{sid}.jsonl", sid)
58
+ session.meta(cwd=str(self.root), model=model)
59
+ return session
60
+
61
+ def list(self, limit: int = 20) -> List[Dict[str, Any]]:
62
+ if not self.dir.is_dir():
63
+ return []
64
+ files = sorted(self.dir.glob("*.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True)
65
+ out = []
66
+ for p in files[:limit]:
67
+ messages, meta = self._replay(p)
68
+ first = next((m.get("content") for m in messages
69
+ if m.get("role") == "user" and isinstance(m.get("content"), str)), "")
70
+ if not messages:
71
+ continue
72
+ out.append({"id": p.stem, "updated": p.stat().st_mtime, "model": meta.get("model"),
73
+ "messages": len(messages), "title": (first or "").strip().splitlines()[0][:70] if first else ""})
74
+ return out
75
+
76
+ def latest(self) -> Optional[str]:
77
+ items = self.list(limit=1)
78
+ return items[0]["id"] if items else None
79
+
80
+ def load(self, session_id: str) -> Tuple[Session, List[Dict[str, Any]], Dict[str, Any]]:
81
+ matches = [p for p in self.dir.glob("*.jsonl") if p.stem == session_id] or \
82
+ [p for p in self.dir.glob("*.jsonl") if p.stem.startswith(session_id) or session_id in p.stem]
83
+ if not matches:
84
+ raise FileNotFoundError(f"No session '{session_id}' for this project")
85
+ p = matches[0]
86
+ messages, meta = self._replay(p)
87
+ return Session(p, p.stem), messages, meta
88
+
89
+ @staticmethod
90
+ def _replay(path: Path) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
91
+ messages: List[Dict[str, Any]] = []
92
+ meta: Dict[str, Any] = {}
93
+ try:
94
+ with open(path, "r", encoding="utf-8") as f:
95
+ for line in f:
96
+ try:
97
+ rec = json.loads(line)
98
+ except ValueError:
99
+ continue # a crash can leave a partial last line
100
+ kind = rec.get("type")
101
+ if kind == "msg":
102
+ messages.append(rec["message"])
103
+ elif kind == "reset":
104
+ messages = list(rec.get("messages", []))
105
+ elif kind == "meta":
106
+ meta.update({k: v for k, v in rec.items() if k not in ("type", "ts")})
107
+ except OSError:
108
+ pass
109
+ return repair_history(messages), meta
110
+
111
+
112
+ def repair_history(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
113
+ """Give every assistant tool call a result, so a transcript cut mid-turn stays sendable."""
114
+ out: List[Dict[str, Any]] = []
115
+ pending: List[str] = []
116
+ for m in messages:
117
+ if pending and m.get("role") != "tool":
118
+ out.extend({"role": "tool", "tool_call_id": cid, "content": "Interrupted: no result recorded."}
119
+ for cid in pending)
120
+ pending = []
121
+ out.append(m)
122
+ if m.get("role") == "assistant" and m.get("tool_calls"):
123
+ pending = [tc["id"] for tc in m["tool_calls"]]
124
+ elif m.get("role") == "tool" and m.get("tool_call_id") in pending:
125
+ pending.remove(m["tool_call_id"])
126
+ out.extend({"role": "tool", "tool_call_id": cid, "content": "Interrupted: no result recorded."}
127
+ for cid in pending)
128
+ return out
hubble/settings.py ADDED
@@ -0,0 +1,184 @@
1
+ """Layered settings.
2
+
3
+ Precedence (later wins): built-in defaults, ~/.hubble/settings.json,
4
+ <project>/.hubble/settings.json, <project>/.hubble/settings.local.json, CLI flags.
5
+ Credentials come from HUBBLE_* environment variables or .env files (the older AIHUB_* names,
6
+ from before this CLI was renamed, are still read as a fallback).
7
+ """
8
+
9
+ import copy
10
+ import json
11
+ import os
12
+ from pathlib import Path
13
+ from typing import Any, Dict, Optional
14
+
15
+ from hubble.models import DEFAULT_MODEL, PROJECT_DIR
16
+
17
+ HOME_DIR = Path(os.environ.get("HUBBLE_HOME", Path.home() / ".hubble"))
18
+
19
+ DEFAULTS: Dict[str, Any] = {
20
+ "model": DEFAULT_MODEL,
21
+ "base_url": "https://aihub.071129.xyz/v1",
22
+ "temperature": 0.3,
23
+ "max_tokens": 8192,
24
+ "context_window": 128000,
25
+ "auto_compact_ratio": 0.8,
26
+ "max_turns": 40,
27
+ "permission_mode": "default",
28
+ "persona": "code",
29
+ "shell": "auto",
30
+ "shell_timeout": 120,
31
+ "additional_dirs": [],
32
+ "shell_sandbox": "off", # "docker" runs shell commands in an isolated, resource-capped container
33
+ "sandbox_image": "python:3.12-slim",
34
+ "sandbox_memory": "1g",
35
+ "sandbox_cpus": "2",
36
+ "sandbox_network": True,
37
+ "allow_secret_files": False,
38
+ "model_refresh_hours": 0, # 0 = always check on startup; set hours to only recheck when stale
39
+ "fallback_model": "codestral-latest",
40
+ "show_reasoning": False,
41
+ "web_tools": True,
42
+ "web_search": {"engine": "auto"},
43
+ "home_animation": True,
44
+ "permissions": {"allow": [], "deny": []},
45
+ }
46
+
47
+
48
+ class ConfigError(Exception):
49
+ pass
50
+
51
+
52
+ _ENV_PREFIXES = ("HUBBLE_", "AIHUB_") # AIHUB_ is the pre-rename name, kept as a fallback
53
+
54
+
55
+ def _read_env_file(path: Path) -> Dict[str, str]:
56
+ """Only HUBBLE_*/AIHUB_* keys are read, so other secrets in a .env never leak into os.environ."""
57
+ out: Dict[str, str] = {}
58
+ try:
59
+ with open(path, "r", encoding="utf-8") as f:
60
+ for line in f:
61
+ line = line.strip()
62
+ if not line or line.startswith("#") or "=" not in line:
63
+ continue
64
+ key, val = line.split("=", 1)
65
+ key = key.strip().removeprefix("export ").strip()
66
+ if key.startswith(_ENV_PREFIXES):
67
+ out[key] = val.strip().strip("'\"")
68
+ except OSError:
69
+ pass
70
+ return out
71
+
72
+
73
+ def _read_json(path: Path) -> Dict[str, Any]:
74
+ if not path.is_file():
75
+ return {}
76
+ try:
77
+ with open(path, "r", encoding="utf-8") as f:
78
+ data = json.load(f)
79
+ except (OSError, ValueError) as e:
80
+ raise ConfigError(f"Invalid settings file {path}: {e}")
81
+ if not isinstance(data, dict):
82
+ raise ConfigError(f"Settings file {path} must contain a JSON object")
83
+ return data
84
+
85
+
86
+ def _merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
87
+ for key, val in override.items():
88
+ if key == "permissions" and isinstance(val, dict):
89
+ perms = base.setdefault("permissions", {"allow": [], "deny": []})
90
+ for kind in ("allow", "deny"):
91
+ perms[kind] = list(perms.get(kind, [])) + list(val.get(kind, []))
92
+ elif isinstance(val, dict) and isinstance(base.get(key), dict):
93
+ base[key] = _merge(dict(base[key]), val)
94
+ else:
95
+ base[key] = val
96
+ return base
97
+
98
+
99
+ # A cloned repository must not be able to loosen security or redirect the API key.
100
+ NEVER_FROM_PROJECT = {"base_url", "api_key"}
101
+ TRUSTED_ONLY = {"permission_mode", "allow_secret_files", "additional_dirs", "shell",
102
+ "shell_sandbox", "sandbox_network"}
103
+ TRUST_FILE = HOME_DIR / "trusted_folders.json"
104
+
105
+
106
+ def _trusted_folders() -> list:
107
+ try:
108
+ data = json.loads(TRUST_FILE.read_text(encoding="utf-8"))
109
+ return data if isinstance(data, list) else []
110
+ except (OSError, ValueError):
111
+ return []
112
+
113
+
114
+ def is_trusted(root: Path) -> bool:
115
+ key = str(root.resolve()).lower()
116
+ return any(key == str(t).lower() or key.startswith(str(t).lower().rstrip("\\/") + os.sep)
117
+ for t in _trusted_folders())
118
+
119
+
120
+ def trust_folder(root: Path):
121
+ folders = _trusted_folders()
122
+ if str(root.resolve()) not in folders:
123
+ folders.append(str(root.resolve()))
124
+ HOME_DIR.mkdir(parents=True, exist_ok=True)
125
+ TRUST_FILE.write_text(json.dumps(folders, indent=2), encoding="utf-8")
126
+
127
+
128
+ def _filter_project(data: Dict[str, Any], trusted: bool, ignored: list) -> Dict[str, Any]:
129
+ out = {}
130
+ for key, val in data.items():
131
+ if key in NEVER_FROM_PROJECT or (key in TRUSTED_ONLY and not trusted):
132
+ ignored.append(key)
133
+ continue
134
+ if key == "permissions" and isinstance(val, dict) and not trusted and val.get("allow"):
135
+ ignored.append("permissions.allow")
136
+ val = {"deny": val.get("deny", [])} # deny rules only tighten, so they always apply
137
+ out[key] = val
138
+ return out
139
+
140
+
141
+ def load_settings(root: Path, overrides: Optional[Dict[str, Any]] = None,
142
+ trusted: Optional[bool] = None) -> Dict[str, Any]:
143
+ if trusted is None:
144
+ trusted = is_trusted(root)
145
+ settings = copy.deepcopy(DEFAULTS)
146
+ settings = _merge(settings, _read_json(HOME_DIR / "settings.json"))
147
+ ignored: list = []
148
+ for path in (root / ".hubble" / "settings.json", root / ".hubble" / "settings.local.json"):
149
+ settings = _merge(settings, _filter_project(_read_json(path), trusted, ignored))
150
+ settings["_ignored_project_keys"] = sorted(set(ignored))
151
+ settings = _merge(settings, {k: v for k, v in (overrides or {}).items() if v is not None})
152
+
153
+ env: Dict[str, str] = {}
154
+ for env_file in (HOME_DIR / ".env", PROJECT_DIR / ".env"):
155
+ for k, v in _read_env_file(env_file).items():
156
+ env.setdefault(k, v)
157
+ env.update({k: v for k, v in os.environ.items() if k.startswith(_ENV_PREFIXES)})
158
+
159
+ if not settings.get("api_key"):
160
+ settings["api_key"] = env.get("HUBBLE_API_KEY") or env.get("AIHUB_API_KEY", "")
161
+ if overrides is None or overrides.get("base_url") is None:
162
+ settings["base_url"] = env.get("HUBBLE_BASE_URL") or env.get("AIHUB_BASE_URL") or settings["base_url"]
163
+ return settings
164
+
165
+
166
+ def require_api_key(settings: Dict[str, Any]) -> str:
167
+ key = settings.get("api_key") or ""
168
+ if not key:
169
+ raise ConfigError(
170
+ "Missing HUBBLE_API_KEY. Set it in one of:\n"
171
+ f" {PROJECT_DIR / '.env'}\n"
172
+ f" {HOME_DIR / '.env'}\n"
173
+ " or the environment: $env:HUBBLE_API_KEY=\"...\" (PowerShell) / export HUBBLE_API_KEY=... (bash)"
174
+ )
175
+ return key
176
+
177
+
178
+ def save_user_setting(key: str, value: Any):
179
+ """Persist one value in ~/.hubble/settings.json, keeping everything else in the file."""
180
+ path = HOME_DIR / "settings.json"
181
+ data = _read_json(path)
182
+ data[key] = value
183
+ HOME_DIR.mkdir(parents=True, exist_ok=True)
184
+ path.write_text(json.dumps(data, indent=2), encoding="utf-8")
hubble/skills.py ADDED
@@ -0,0 +1,173 @@
1
+ """Skills: reusable instructions the model can call itself, or the user can run with /<name>.
2
+
3
+ A skill is `<name>/SKILL.md` (with resources alongside it) or a plain `<name>.md`, under
4
+ `~/.hubble/skills/` (user, every project) or `<project>/.hubble/skills/` (project only).
5
+ It needs `name` and `description` frontmatter; the description is what tells the model,
6
+ and the user, when to reach for it — only the description is loaded into the system prompt,
7
+ and the full body loads on demand, so adding skills does not bloat every request.
8
+ """
9
+
10
+ import re
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import Dict, List, Optional
14
+
15
+ from hubble.settings import HOME_DIR
16
+ from hubble.tools import Tool, ToolContext, ToolError, truncate
17
+
18
+ NAME_RX = re.compile(r"^[a-z0-9][a-z0-9_-]{0,40}$")
19
+ FRONTMATTER_RX = re.compile(r"^---\s*\n(.*?)\n---\s*\n?(.*)$", re.DOTALL)
20
+
21
+ TEMPLATE = """---
22
+ name: {name}
23
+ description: One sentence saying what this does and, importantly, WHEN to use it (the model \
24
+ and /help match on this). Example: "Run the project's test suite and summarize failures. Use \
25
+ when asked to test, check or verify the code works."
26
+ ---
27
+
28
+ # {title}
29
+
30
+ Step-by-step instructions for the model to follow when this skill is invoked. Be concrete:
31
+ name the exact commands, files or conventions this project uses.
32
+ """
33
+
34
+
35
+ @dataclass
36
+ class Skill:
37
+ name: str
38
+ description: str
39
+ path: Path
40
+ scope: str # "user" | "project"
41
+ args_hint: str = ""
42
+ _body: Optional[str] = field(default=None, repr=False)
43
+
44
+ def body(self) -> str:
45
+ if self._body is None:
46
+ try:
47
+ self._body = _parse(self.path)[1]
48
+ except OSError as e:
49
+ self._body = f"(could not read {self.path}: {e})"
50
+ return self._body
51
+
52
+
53
+ def _parse(path: Path) -> tuple:
54
+ """Returns (frontmatter dict, body). A file with no frontmatter is treated as pure body."""
55
+ text = path.read_text(encoding="utf-8")
56
+ m = FRONTMATTER_RX.match(text)
57
+ if not m:
58
+ return {}, text
59
+ meta: Dict[str, str] = {}
60
+ for line in m.group(1).splitlines():
61
+ if ":" in line and not line.startswith((" ", "\t", "-")):
62
+ key, _, val = line.partition(":")
63
+ meta[key.strip().lower()] = val.strip().strip("'\"")
64
+ return meta, m.group(2)
65
+
66
+
67
+ def _skill_dirs(root: Path) -> List[tuple]:
68
+ return [(HOME_DIR / "skills", "user"), (root / ".hubble" / "skills", "project")]
69
+
70
+
71
+ def discover_skills(root: Path) -> List[Skill]:
72
+ """Project skills come after user skills and win on a name clash (more specific wins)."""
73
+ found: Dict[str, Skill] = {}
74
+ for base, scope in _skill_dirs(root):
75
+ if not base.is_dir():
76
+ continue
77
+ candidates = sorted(base.glob("*/SKILL.md")) + sorted(base.glob("*.md"))
78
+ for path in candidates:
79
+ name = (path.parent.name if path.name == "SKILL.md" else path.stem).lower()
80
+ if not NAME_RX.match(name):
81
+ continue
82
+ try:
83
+ meta, body = _parse(path)
84
+ except OSError:
85
+ continue
86
+ description = meta.get("description") or next(
87
+ (l.strip("# ").strip() for l in body.splitlines() if l.strip()), "") or "(no description)"
88
+ found[name] = Skill(name=meta.get("name", name).lower() or name, description=description,
89
+ path=path, scope=scope, args_hint=meta.get("argument-hint", ""))
90
+ return sorted(found.values(), key=lambda s: s.name)
91
+
92
+
93
+ def skills_prompt_block(skills: List[Skill]) -> str:
94
+ """Name + description only (progressive disclosure); call the skill tool for the full body."""
95
+ if not skills:
96
+ return ""
97
+ lines = [f'- {s.name}: {s.description}' for s in skills]
98
+ return ("# Skills\nReusable instructions for recurring tasks in this project. If one clearly matches "
99
+ "what the user asked, call the skill tool to load its full instructions before proceeding. "
100
+ "Do not call it speculatively; only when its description matches.\n" + "\n".join(lines))
101
+
102
+
103
+ class SkillTool(Tool):
104
+ name = "skill"
105
+ description = ("Load the full instructions for a named skill (see the Skills section of your system "
106
+ "prompt for names and descriptions). Call this before following a skill's steps.")
107
+ parameters = {"type": "object", "properties": {
108
+ "name": {"type": "string", "description": "Skill name, exactly as listed"},
109
+ }, "required": ["name"]}
110
+ kind = "read"
111
+
112
+ def __init__(self, skills: List[Skill]):
113
+ self.by_name = {s.name: s for s in skills}
114
+
115
+ def target(self, args):
116
+ return args.get("name", "")
117
+
118
+ def run(self, args, ctx):
119
+ skill = self.by_name.get(str(args["name"]).lower())
120
+ if not skill:
121
+ available = ", ".join(sorted(self.by_name)) or "(none)"
122
+ raise ToolError(f"no skill named '{args['name']}'. Available: {available}")
123
+ return truncate(skill.body(), 40000)
124
+
125
+
126
+ class WriteSkillTool(Tool):
127
+ name = "write_skill"
128
+ description = ("Save a reusable skill so future sessions in this project can call it by name. Use this "
129
+ "when you notice a multi-step procedure the user is likely to repeat (a release "
130
+ "checklist, a debugging recipe, project-specific conventions) — not for one-off tasks. "
131
+ "Ask the user before writing one unless they asked you to save it.")
132
+ parameters = {"type": "object", "properties": {
133
+ "name": {"type": "string", "description": "lowercase-with-hyphens, becomes the /name command"},
134
+ "description": {"type": "string",
135
+ "description": "One sentence: what it does and when to use it (for matching)"},
136
+ "body": {"type": "string", "description": "Full instructions in Markdown"},
137
+ "scope": {"type": "string", "enum": ["project", "user"],
138
+ "description": "project (default; this repo only) or user (every project)"},
139
+ }, "required": ["name", "description", "body"]}
140
+ kind = "edit"
141
+
142
+ def _path(self, args, ctx: ToolContext) -> Path:
143
+ name = str(args["name"]).lower().strip()
144
+ if not NAME_RX.match(name):
145
+ raise ToolError("name must be lowercase letters, digits, - or _ (max 41 chars)")
146
+ base = HOME_DIR / "skills" if args.get("scope") == "user" else ctx.root / ".hubble" / "skills"
147
+ return base / name / "SKILL.md"
148
+
149
+ def _content(self, args) -> str:
150
+ return f"---\nname: {args['name']}\ndescription: {args['description']}\n---\n\n{args['body'].strip()}\n"
151
+
152
+ def target(self, args):
153
+ return str(args.get("name", ""))
154
+
155
+ def preview(self, args, ctx):
156
+ try:
157
+ path = self._path(args, ctx)
158
+ except ToolError as e:
159
+ return f"(will fail: {e})"
160
+ old = path.read_text(encoding="utf-8") if path.is_file() else ""
161
+ new = self._content(args)
162
+ from hubble.tools import unified_diff
163
+ return unified_diff(old, new, str(path)) or "(no changes)"
164
+
165
+ def run(self, args, ctx: ToolContext):
166
+ path = self._path(args, ctx)
167
+ existed = path.is_file()
168
+ path.parent.mkdir(parents=True, exist_ok=True)
169
+ ctx.snapshot(path)
170
+ path.write_text(self._content(args), encoding="utf-8")
171
+ where = "every project" if args.get("scope") == "user" else "this project"
172
+ return f"{'Updated' if existed else 'Saved'} skill '{args['name']}' for {where} at {path}. " \
173
+ f"Available immediately as /{args['name']} or the skill tool."