luckyd-code 1.2.2__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.
- luckyd_code/__init__.py +54 -0
- luckyd_code/__main__.py +5 -0
- luckyd_code/_agent_loop.py +551 -0
- luckyd_code/_data_dir.py +73 -0
- luckyd_code/agent.py +38 -0
- luckyd_code/analytics/__init__.py +18 -0
- luckyd_code/analytics/reporter.py +195 -0
- luckyd_code/analytics/scanner.py +443 -0
- luckyd_code/analytics/smells.py +316 -0
- luckyd_code/analytics/trends.py +303 -0
- luckyd_code/api.py +473 -0
- luckyd_code/audit_daemon.py +845 -0
- luckyd_code/autonomous_fixer.py +473 -0
- luckyd_code/background.py +159 -0
- luckyd_code/backup.py +237 -0
- luckyd_code/brain/__init__.py +84 -0
- luckyd_code/brain/assembler.py +100 -0
- luckyd_code/brain/chunker.py +345 -0
- luckyd_code/brain/constants.py +73 -0
- luckyd_code/brain/embedder.py +163 -0
- luckyd_code/brain/graph.py +311 -0
- luckyd_code/brain/indexer.py +316 -0
- luckyd_code/brain/parser.py +140 -0
- luckyd_code/brain/retriever.py +234 -0
- luckyd_code/cli.py +894 -0
- luckyd_code/cli_commands/__init__.py +1 -0
- luckyd_code/cli_commands/audit.py +120 -0
- luckyd_code/cli_commands/background.py +83 -0
- luckyd_code/cli_commands/brain.py +87 -0
- luckyd_code/cli_commands/config.py +75 -0
- luckyd_code/cli_commands/dispatcher.py +695 -0
- luckyd_code/cli_commands/sessions.py +41 -0
- luckyd_code/cli_entry.py +147 -0
- luckyd_code/cli_utils.py +112 -0
- luckyd_code/config.py +205 -0
- luckyd_code/context.py +214 -0
- luckyd_code/cost_tracker.py +209 -0
- luckyd_code/error_reporter.py +508 -0
- luckyd_code/exceptions.py +39 -0
- luckyd_code/export.py +126 -0
- luckyd_code/feedback_analyzer.py +290 -0
- luckyd_code/file_watcher.py +258 -0
- luckyd_code/git/__init__.py +11 -0
- luckyd_code/git/auto_commit.py +157 -0
- luckyd_code/git/tools.py +85 -0
- luckyd_code/hooks.py +236 -0
- luckyd_code/indexer.py +280 -0
- luckyd_code/init.py +39 -0
- luckyd_code/keybindings.py +77 -0
- luckyd_code/log.py +55 -0
- luckyd_code/mcp/__init__.py +6 -0
- luckyd_code/mcp/client.py +184 -0
- luckyd_code/memory/__init__.py +19 -0
- luckyd_code/memory/manager.py +339 -0
- luckyd_code/metrics/__init__.py +5 -0
- luckyd_code/model_registry.py +131 -0
- luckyd_code/orchestrator.py +204 -0
- luckyd_code/permissions/__init__.py +1 -0
- luckyd_code/permissions/manager.py +103 -0
- luckyd_code/planner.py +361 -0
- luckyd_code/plugins.py +91 -0
- luckyd_code/py.typed +0 -0
- luckyd_code/retry.py +57 -0
- luckyd_code/router.py +417 -0
- luckyd_code/sandbox.py +156 -0
- luckyd_code/self_critique.py +2 -0
- luckyd_code/self_improve.py +274 -0
- luckyd_code/sessions.py +114 -0
- luckyd_code/settings.py +72 -0
- luckyd_code/skills/__init__.py +8 -0
- luckyd_code/skills/review.py +22 -0
- luckyd_code/skills/security.py +17 -0
- luckyd_code/tasks/__init__.py +1 -0
- luckyd_code/tasks/manager.py +102 -0
- luckyd_code/templates/icon-192.png +0 -0
- luckyd_code/templates/icon-512.png +0 -0
- luckyd_code/templates/index.html +1965 -0
- luckyd_code/templates/manifest.json +14 -0
- luckyd_code/templates/src/app.js +694 -0
- luckyd_code/templates/src/body.html +767 -0
- luckyd_code/templates/src/cdn.txt +2 -0
- luckyd_code/templates/src/style.css +474 -0
- luckyd_code/templates/sw.js +31 -0
- luckyd_code/templates/test.html +6 -0
- luckyd_code/themes.py +48 -0
- luckyd_code/tools/__init__.py +97 -0
- luckyd_code/tools/agent_tools.py +65 -0
- luckyd_code/tools/bash.py +360 -0
- luckyd_code/tools/brain_tools.py +137 -0
- luckyd_code/tools/browser.py +369 -0
- luckyd_code/tools/datetime_tool.py +34 -0
- luckyd_code/tools/dockerfile_gen.py +212 -0
- luckyd_code/tools/file_ops.py +381 -0
- luckyd_code/tools/game_gen.py +360 -0
- luckyd_code/tools/git_tools.py +130 -0
- luckyd_code/tools/git_worktree.py +63 -0
- luckyd_code/tools/path_validate.py +64 -0
- luckyd_code/tools/project_gen.py +187 -0
- luckyd_code/tools/readme_gen.py +227 -0
- luckyd_code/tools/registry.py +157 -0
- luckyd_code/tools/shell_detect.py +109 -0
- luckyd_code/tools/web.py +89 -0
- luckyd_code/tools/youtube.py +187 -0
- luckyd_code/tools_bridge.py +144 -0
- luckyd_code/undo.py +126 -0
- luckyd_code/update.py +60 -0
- luckyd_code/verify.py +360 -0
- luckyd_code/web_app.py +176 -0
- luckyd_code/web_routes/__init__.py +23 -0
- luckyd_code/web_routes/background.py +73 -0
- luckyd_code/web_routes/brain.py +109 -0
- luckyd_code/web_routes/cost.py +12 -0
- luckyd_code/web_routes/files.py +133 -0
- luckyd_code/web_routes/memories.py +94 -0
- luckyd_code/web_routes/misc.py +67 -0
- luckyd_code/web_routes/project.py +48 -0
- luckyd_code/web_routes/review.py +20 -0
- luckyd_code/web_routes/sessions.py +44 -0
- luckyd_code/web_routes/settings.py +43 -0
- luckyd_code/web_routes/static.py +70 -0
- luckyd_code/web_routes/update.py +19 -0
- luckyd_code/web_routes/ws.py +237 -0
- luckyd_code-1.2.2.dist-info/METADATA +297 -0
- luckyd_code-1.2.2.dist-info/RECORD +127 -0
- luckyd_code-1.2.2.dist-info/WHEEL +4 -0
- luckyd_code-1.2.2.dist-info/entry_points.txt +3 -0
- luckyd_code-1.2.2.dist-info/licenses/LICENSE +21 -0
luckyd_code/tools/web.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
from urllib.parse import quote as url_quote
|
|
3
|
+
from bs4 import BeautifulSoup
|
|
4
|
+
|
|
5
|
+
from .registry import Tool
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class WebFetchTool(Tool):
|
|
9
|
+
name = "WebFetch"
|
|
10
|
+
description = "Fetch content from a URL and extract its text content."
|
|
11
|
+
parameters = {
|
|
12
|
+
"type": "object",
|
|
13
|
+
"properties": {
|
|
14
|
+
"url": {
|
|
15
|
+
"type": "string",
|
|
16
|
+
"description": "The URL to fetch",
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
"required": ["url"],
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
def run(self, url: str) -> str: # type: ignore[override]
|
|
23
|
+
try:
|
|
24
|
+
response = httpx.get(url, follow_redirects=True, timeout=30)
|
|
25
|
+
response.raise_for_status()
|
|
26
|
+
content_type = response.headers.get("content-type", "")
|
|
27
|
+
if "text/html" in content_type:
|
|
28
|
+
soup = BeautifulSoup(response.text, "html.parser")
|
|
29
|
+
for tag in soup(["script", "style", "nav", "footer", "header"]):
|
|
30
|
+
tag.decompose()
|
|
31
|
+
text = soup.get_text(separator="\n", strip=True)
|
|
32
|
+
lines = [line.strip() for line in text.splitlines() if line.strip()]
|
|
33
|
+
text = "\n".join(lines)
|
|
34
|
+
else:
|
|
35
|
+
text = response.text
|
|
36
|
+
|
|
37
|
+
if len(text) > 15000:
|
|
38
|
+
text = text[:15000] + f"\n... (truncated, {len(text)} total chars)"
|
|
39
|
+
return text
|
|
40
|
+
except httpx.HTTPStatusError as e:
|
|
41
|
+
return f"HTTP error {e.response.status_code}: {e.response.text[:500]}"
|
|
42
|
+
except Exception as e:
|
|
43
|
+
return f"Error fetching URL: {e}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class WebSearchTool(Tool):
|
|
47
|
+
name = "WebSearch"
|
|
48
|
+
description = "Search the web and get results."
|
|
49
|
+
parameters = {
|
|
50
|
+
"type": "object",
|
|
51
|
+
"properties": {
|
|
52
|
+
"query": {
|
|
53
|
+
"type": "string",
|
|
54
|
+
"description": "The search query",
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
"required": ["query"],
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
def run(self, query: str) -> str: # type: ignore[override]
|
|
61
|
+
# Simple DuckDuckGo-like search via a public endpoint
|
|
62
|
+
try:
|
|
63
|
+
url = f"https://html.duckduckgo.com/html/?q={url_quote(query)}"
|
|
64
|
+
response = httpx.get(
|
|
65
|
+
url,
|
|
66
|
+
headers={
|
|
67
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
|
68
|
+
},
|
|
69
|
+
follow_redirects=True,
|
|
70
|
+
timeout=30,
|
|
71
|
+
)
|
|
72
|
+
response.raise_for_status()
|
|
73
|
+
|
|
74
|
+
soup = BeautifulSoup(response.text, "html.parser")
|
|
75
|
+
results = []
|
|
76
|
+
for result in soup.select(".result")[:10]:
|
|
77
|
+
title_el = result.select_one(".result__title a")
|
|
78
|
+
snippet_el = result.select_one(".result__snippet")
|
|
79
|
+
if title_el:
|
|
80
|
+
title = title_el.get_text(strip=True)
|
|
81
|
+
link = title_el.get("href", "")
|
|
82
|
+
snippet = snippet_el.get_text(strip=True) if snippet_el else ""
|
|
83
|
+
results.append(f"{title}\n URL: {link}\n {snippet}")
|
|
84
|
+
|
|
85
|
+
if not results:
|
|
86
|
+
return f"No results found for '{query}'"
|
|
87
|
+
return "\n\n".join(results)
|
|
88
|
+
except Exception as e:
|
|
89
|
+
return f"Search error: {e}"
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""YouTube playlist generator tool.
|
|
2
|
+
|
|
3
|
+
Converts a list of YouTube URLs or video IDs into a temporary playlist URL
|
|
4
|
+
that works without a YouTube account (YouTube's watch_videos endpoint).
|
|
5
|
+
Supports up to 50 videos per playlist (YouTube's hard limit for this endpoint).
|
|
6
|
+
|
|
7
|
+
Integrated into the tool registry so the AI agent can generate playlists
|
|
8
|
+
on demand during a coding/automation session.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from urllib.parse import urlencode, urlparse, parse_qs
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
from .registry import Tool
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
# Constants
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
PLAYLIST_BASE = "https://www.youtube.com/watch_videos"
|
|
22
|
+
MAX_VIDEOS = 50 # YouTube's limit for the watch_videos endpoint
|
|
23
|
+
|
|
24
|
+
# Regex patterns for extracting video IDs from various URL formats.
|
|
25
|
+
# Handles: youtu.be/<id>, youtube.com/watch?v=<id>, /embed/<id>, /shorts/<id>
|
|
26
|
+
_YT_ID_PATTERN = re.compile(
|
|
27
|
+
r"(?:youtu\.be/|youtube\.com/(?:watch\?.*?v=|embed/|shorts/|v/))"
|
|
28
|
+
r"([A-Za-z0-9_-]{11})"
|
|
29
|
+
)
|
|
30
|
+
# A bare 11-character video ID with no URL wrapper
|
|
31
|
+
_BARE_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{11}$")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# Core extraction logic (reused by both Tool and CLI script)
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
def extract_video_id(raw: str) -> Optional[str]:
|
|
39
|
+
"""Extract an 11-character YouTube video ID from a URL or bare ID string.
|
|
40
|
+
|
|
41
|
+
Returns the ID string on success, or None if the input is not recognised
|
|
42
|
+
as a valid YouTube reference.
|
|
43
|
+
"""
|
|
44
|
+
raw = raw.strip()
|
|
45
|
+
if not raw:
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
# Try URL-based regex extraction first (covers youtu.be, /embed, /shorts)
|
|
49
|
+
match = _YT_ID_PATTERN.search(raw)
|
|
50
|
+
if match:
|
|
51
|
+
return match.group(1)
|
|
52
|
+
|
|
53
|
+
# Handle youtube.com/watch?v=ID where v= may not be the first parameter
|
|
54
|
+
try:
|
|
55
|
+
parsed = urlparse(raw)
|
|
56
|
+
if "youtube.com" in parsed.netloc:
|
|
57
|
+
qs = parse_qs(parsed.query)
|
|
58
|
+
if "v" in qs:
|
|
59
|
+
vid = qs["v"][0]
|
|
60
|
+
if len(vid) == 11:
|
|
61
|
+
return vid
|
|
62
|
+
except Exception:
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
# Fall back: accept a bare 11-char ID (only valid YouTube ID characters)
|
|
66
|
+
if _BARE_ID_PATTERN.match(raw):
|
|
67
|
+
return raw
|
|
68
|
+
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def build_playlist_url(video_ids: list) -> str:
|
|
73
|
+
"""Construct a YouTube watch_videos URL from a list of video IDs.
|
|
74
|
+
|
|
75
|
+
Uses safe="," to prevent urlencode from percent-encoding the commas
|
|
76
|
+
between video IDs — YouTube's watch_videos endpoint expects literal commas.
|
|
77
|
+
"""
|
|
78
|
+
if not video_ids:
|
|
79
|
+
raise ValueError("No video IDs provided — cannot build a playlist URL.")
|
|
80
|
+
params = urlencode({"video_ids": ",".join(video_ids)}, safe=",")
|
|
81
|
+
return f"{PLAYLIST_BASE}?{params}"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def process_inputs(raw_inputs: list, cap: int = MAX_VIDEOS) -> tuple:
|
|
85
|
+
"""Parse raw input strings into (valid_ids, skipped_inputs).
|
|
86
|
+
|
|
87
|
+
Deduplicates IDs while preserving order. Enforces the video cap and
|
|
88
|
+
records a warning entry in *skipped* for any videos over the limit.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
raw_inputs: Raw URL/ID strings to process.
|
|
92
|
+
cap: Maximum number of videos to accept (default: MAX_VIDEOS = 50).
|
|
93
|
+
"""
|
|
94
|
+
seen: set = set()
|
|
95
|
+
valid: list = []
|
|
96
|
+
skipped: list = []
|
|
97
|
+
|
|
98
|
+
for raw in raw_inputs:
|
|
99
|
+
vid = extract_video_id(raw)
|
|
100
|
+
if vid is None:
|
|
101
|
+
skipped.append(f"Invalid: {raw!r}")
|
|
102
|
+
continue
|
|
103
|
+
if vid in seen:
|
|
104
|
+
skipped.append(f"Duplicate: {raw!r}")
|
|
105
|
+
continue
|
|
106
|
+
if len(valid) >= cap:
|
|
107
|
+
skipped.append(f"Over {cap}-video limit: {raw!r}")
|
|
108
|
+
continue
|
|
109
|
+
seen.add(vid)
|
|
110
|
+
valid.append(vid)
|
|
111
|
+
|
|
112
|
+
return valid, skipped
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
# Tool class — integrates with the DeepSeek Code tool registry
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
class YouTubePlaylistTool(Tool):
|
|
120
|
+
"""Generate a temporary YouTube playlist URL from a list of video URLs or IDs.
|
|
121
|
+
|
|
122
|
+
Use this tool whenever the user asks to:
|
|
123
|
+
- Create or build a YouTube playlist
|
|
124
|
+
- Combine multiple YouTube videos into a single watchable link
|
|
125
|
+
- Generate a shareable playlist without a YouTube account
|
|
126
|
+
- Produce a playlist URL from a list of video URLs or IDs
|
|
127
|
+
|
|
128
|
+
The playlist URL opens immediately in any browser — no login required.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
name = "YouTubePlaylist"
|
|
132
|
+
description = (
|
|
133
|
+
"Use when the user wants to combine YouTube videos into a playlist or generate "
|
|
134
|
+
"a shareable playlist URL. Accepts any mix of YouTube URL formats (full URLs, "
|
|
135
|
+
"youtu.be short links, embed/shorts URLs, or bare 11-char video IDs) and returns "
|
|
136
|
+
"a ready-to-open playlist link. No YouTube account required. Supports up to 50 videos. "
|
|
137
|
+
"Also reports any invalid or duplicate inputs that were skipped."
|
|
138
|
+
)
|
|
139
|
+
parameters = {
|
|
140
|
+
"type": "object",
|
|
141
|
+
"properties": {
|
|
142
|
+
"videos": {
|
|
143
|
+
"type": "array",
|
|
144
|
+
"items": {"type": "string"},
|
|
145
|
+
"description": (
|
|
146
|
+
"List of YouTube video URLs (any format: full URL, youtu.be short "
|
|
147
|
+
"link, embed URL, shorts URL) or bare 11-character video IDs."
|
|
148
|
+
),
|
|
149
|
+
},
|
|
150
|
+
"max_videos": {
|
|
151
|
+
"type": "integer",
|
|
152
|
+
"description": (
|
|
153
|
+
f"Maximum number of videos to include (1–{MAX_VIDEOS}). "
|
|
154
|
+
f"Defaults to {MAX_VIDEOS}. Videos beyond this cap are reported as skipped."
|
|
155
|
+
),
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
"required": ["videos"],
|
|
159
|
+
}
|
|
160
|
+
permission_risk = "safe"
|
|
161
|
+
|
|
162
|
+
def run(self, videos: list, max_videos: int = MAX_VIDEOS) -> str: # type: ignore[override]
|
|
163
|
+
if not videos:
|
|
164
|
+
return "Error: no videos provided."
|
|
165
|
+
|
|
166
|
+
# Clamp max_videos to the hard YouTube limit
|
|
167
|
+
cap = max(1, min(max_videos, MAX_VIDEOS))
|
|
168
|
+
|
|
169
|
+
valid_ids, skipped = process_inputs(videos, cap=cap)
|
|
170
|
+
lines: list = []
|
|
171
|
+
|
|
172
|
+
if not valid_ids:
|
|
173
|
+
lines.append("Error: no valid YouTube video IDs found.")
|
|
174
|
+
if skipped:
|
|
175
|
+
lines.append(f"Skipped {len(skipped)} input(s):")
|
|
176
|
+
lines.extend(f" • {s}" for s in skipped)
|
|
177
|
+
return "\n".join(lines)
|
|
178
|
+
|
|
179
|
+
url = build_playlist_url(valid_ids)
|
|
180
|
+
lines.append(f"Playlist URL ({len(valid_ids)} video(s)):")
|
|
181
|
+
lines.append(url)
|
|
182
|
+
|
|
183
|
+
if skipped:
|
|
184
|
+
lines.append(f"\nSkipped {len(skipped)} input(s):")
|
|
185
|
+
lines.extend(f" • {s}" for s in skipped)
|
|
186
|
+
|
|
187
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Tools Bridge — exposes all tools as CLI commands.
|
|
2
|
+
|
|
3
|
+
This makes every tool in the registry callable via:
|
|
4
|
+
python -m luckyd_code.tools_bridge <tool> [args...]
|
|
5
|
+
|
|
6
|
+
Examples:
|
|
7
|
+
python -m luckyd_code.tools_bridge browser navigate --url https://example.com
|
|
8
|
+
python -m luckyd_code.tools_bridge bash --command "dir /b"
|
|
9
|
+
python -m luckyd_code.tools_bridge brain search --query "authentication"
|
|
10
|
+
"""
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
|
|
17
|
+
# Ensure the project root is on sys.path
|
|
18
|
+
_project_root = Path(__file__).resolve().parent.parent
|
|
19
|
+
if str(_project_root) not in sys.path:
|
|
20
|
+
sys.path.insert(0, str(_project_root))
|
|
21
|
+
|
|
22
|
+
_console = Console()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _parse_args(raw_args: list[str]) -> dict:
|
|
26
|
+
"""Parse KEY=VALUE or --flag VALUE pairs into a dict."""
|
|
27
|
+
result = {}
|
|
28
|
+
key = None
|
|
29
|
+
for item in raw_args:
|
|
30
|
+
if item.startswith("--"):
|
|
31
|
+
if key is not None:
|
|
32
|
+
result[key] = True # flag
|
|
33
|
+
key = item.removeprefix("--")
|
|
34
|
+
elif key is not None:
|
|
35
|
+
try:
|
|
36
|
+
result[key] = json.loads(item)
|
|
37
|
+
except (json.JSONDecodeError, TypeError):
|
|
38
|
+
result[key] = item
|
|
39
|
+
key = None
|
|
40
|
+
elif "=" in item:
|
|
41
|
+
k, v = item.split("=", 1)
|
|
42
|
+
try:
|
|
43
|
+
result[k] = json.loads(v)
|
|
44
|
+
except (json.JSONDecodeError, TypeError):
|
|
45
|
+
result[k] = v
|
|
46
|
+
if key is not None:
|
|
47
|
+
result[key] = True
|
|
48
|
+
return result
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _import_tool(tool_name: str):
|
|
52
|
+
"""Import and return a tool instance by name."""
|
|
53
|
+
from luckyd_code.tools import get_default_registry
|
|
54
|
+
registry = get_default_registry()
|
|
55
|
+
tool = registry.get(tool_name)
|
|
56
|
+
if tool is None:
|
|
57
|
+
_console.print(f"Unknown tool: {tool_name}", style="red")
|
|
58
|
+
_console.print("\nAvailable tools:")
|
|
59
|
+
for t in sorted(registry._tools.keys()):
|
|
60
|
+
_console.print(f" - {t}")
|
|
61
|
+
sys.exit(1)
|
|
62
|
+
return tool
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def cmd_info(tool_name: str):
|
|
66
|
+
"""Show info about a tool."""
|
|
67
|
+
tool = _import_tool(tool_name)
|
|
68
|
+
_console.print(f"Tool: {tool.name}")
|
|
69
|
+
_console.print(f"Risk: {tool.permission_risk}")
|
|
70
|
+
_console.print(f"Desc: {tool.description}")
|
|
71
|
+
_console.print("Params:")
|
|
72
|
+
props = tool.parameters.get("properties", {})
|
|
73
|
+
required = tool.parameters.get("required", [])
|
|
74
|
+
for name, schema in props.items():
|
|
75
|
+
req = " (required)" if name in required else ""
|
|
76
|
+
desc = schema.get("description", "")
|
|
77
|
+
_console.print(f" --{name}: {desc}{req}")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def cmd_run(tool_name: str, *args):
|
|
81
|
+
"""Run a tool with given arguments."""
|
|
82
|
+
tool = _import_tool(tool_name)
|
|
83
|
+
kwargs = _parse_args(list(args))
|
|
84
|
+
result = tool.run(**kwargs)
|
|
85
|
+
_console.print(result)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def cmd_list():
|
|
89
|
+
"""List all registered tools."""
|
|
90
|
+
from luckyd_code.tools import get_default_registry
|
|
91
|
+
registry = get_default_registry()
|
|
92
|
+
_console.print("=" * 60)
|
|
93
|
+
_console.print("Available Tools (via luckyd_code.tools_bridge)")
|
|
94
|
+
_console.print("=" * 60)
|
|
95
|
+
for name, tool in sorted(registry._tools.items()):
|
|
96
|
+
risk_icon = {"safe": "[SAFE]", "medium": "[MED]", "high": "[HIGH]"}.get(tool.permission_risk, "[?]")
|
|
97
|
+
_console.print(f"\n{risk_icon} {name}")
|
|
98
|
+
_console.print(f" {tool.description[:120]}")
|
|
99
|
+
_console.print(f"\n{'=' * 60}")
|
|
100
|
+
_console.print("Usage: python -m luckyd_code.tools_bridge run <ToolName> [--key value ...]")
|
|
101
|
+
_console.print(" python -m luckyd_code.tools_bridge info <ToolName>")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def main():
|
|
105
|
+
if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help"):
|
|
106
|
+
_console.print("deepseek-code Tools Bridge — expose internal tools as CLI")
|
|
107
|
+
_console.print()
|
|
108
|
+
_console.print("Usage:")
|
|
109
|
+
_console.print(" python -m luckyd_code.tools_bridge list")
|
|
110
|
+
_console.print(" python -m luckyd_code.tools_bridge info <ToolName>")
|
|
111
|
+
_console.print(" python -m luckyd_code.tools_bridge run <ToolName> [--key value ...]")
|
|
112
|
+
_console.print()
|
|
113
|
+
_console.print("Examples:")
|
|
114
|
+
_console.print(' python -m luckyd_code.tools_bridge run Bash --command "dir /b"')
|
|
115
|
+
_console.print(' python -m luckyd_code.tools_bridge run WebFetch --url https://example.com')
|
|
116
|
+
_console.print(' python -m luckyd_code.tools_bridge run BrowserNavigate --url https://github.com')
|
|
117
|
+
_console.print(' python -m luckyd_code.tools_bridge run BrainSearch --query "auth"')
|
|
118
|
+
_console.print(' python -m luckyd_code.tools_bridge run GitStatus')
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
command = sys.argv[1]
|
|
122
|
+
rest = sys.argv[2:]
|
|
123
|
+
|
|
124
|
+
if command == "list":
|
|
125
|
+
cmd_list()
|
|
126
|
+
elif command == "info":
|
|
127
|
+
if not rest:
|
|
128
|
+
_console.print("Usage: tools_bridge info <ToolName>", style="yellow")
|
|
129
|
+
sys.exit(1)
|
|
130
|
+
cmd_info(rest[0])
|
|
131
|
+
elif command == "run":
|
|
132
|
+
if not rest:
|
|
133
|
+
_console.print("Usage: tools_bridge run <ToolName> [--key value ...]", style="yellow")
|
|
134
|
+
sys.exit(1)
|
|
135
|
+
tool_name = rest[0]
|
|
136
|
+
cmd_run(tool_name, *rest[1:])
|
|
137
|
+
else:
|
|
138
|
+
_console.print(f"Unknown command: {command}", style="red")
|
|
139
|
+
_console.print("Available: list, info, run")
|
|
140
|
+
sys.exit(1)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
if __name__ == "__main__":
|
|
144
|
+
main()
|
luckyd_code/undo.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Undo support — track file changes and reverse them, with persistence."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from ._data_dir import data_path
|
|
9
|
+
|
|
10
|
+
UNDO_FILE = data_path("undo.json")
|
|
11
|
+
|
|
12
|
+
_logger = logging.getLogger("luckyd_code.undo")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class UndoEntry:
|
|
16
|
+
def __init__(self, file_path: str, original_content: str | None = None, action: str = ""):
|
|
17
|
+
self.file_path = file_path
|
|
18
|
+
self.original_content = original_content
|
|
19
|
+
self.action = action
|
|
20
|
+
|
|
21
|
+
def to_dict(self) -> dict:
|
|
22
|
+
return {
|
|
23
|
+
"file_path": self.file_path,
|
|
24
|
+
"original_content": self.original_content,
|
|
25
|
+
"action": self.action,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def from_dict(cls, d: dict) -> "UndoEntry":
|
|
30
|
+
return cls(
|
|
31
|
+
file_path=d.get("file_path", ""),
|
|
32
|
+
original_content=d.get("original_content"),
|
|
33
|
+
action=d.get("action", ""),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_undo_stack: list[UndoEntry] = []
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _save():
|
|
41
|
+
"""Persist the undo stack to disk."""
|
|
42
|
+
try:
|
|
43
|
+
data = [e.to_dict() for e in _undo_stack]
|
|
44
|
+
UNDO_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
UNDO_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
46
|
+
except Exception:
|
|
47
|
+
_logger.warning("Failed to save undo history", exc_info=True)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _load():
|
|
51
|
+
"""Load the undo stack from disk."""
|
|
52
|
+
global _undo_stack
|
|
53
|
+
try:
|
|
54
|
+
if UNDO_FILE.exists():
|
|
55
|
+
data = json.loads(UNDO_FILE.read_text(encoding="utf-8"))
|
|
56
|
+
_undo_stack = [UndoEntry.from_dict(d) for d in data]
|
|
57
|
+
else:
|
|
58
|
+
_undo_stack = []
|
|
59
|
+
except Exception:
|
|
60
|
+
_logger.warning("Failed to load undo history", exc_info=True)
|
|
61
|
+
_undo_stack = []
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def push(file_path: str, original_content: str | None = None, action: str = ""):
|
|
65
|
+
"""Push an undo entry and persist."""
|
|
66
|
+
_undo_stack.append(UndoEntry(file_path, original_content, action))
|
|
67
|
+
_save()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def pop() -> Optional[UndoEntry]:
|
|
71
|
+
"""Pop and return the last undo entry."""
|
|
72
|
+
if _undo_stack:
|
|
73
|
+
entry = _undo_stack.pop()
|
|
74
|
+
_save()
|
|
75
|
+
return entry
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def peek() -> Optional[UndoEntry]:
|
|
80
|
+
"""Peek at the last undo entry without popping."""
|
|
81
|
+
if _undo_stack:
|
|
82
|
+
return _undo_stack[-1]
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def clear():
|
|
87
|
+
"""Clear all undo history."""
|
|
88
|
+
_undo_stack.clear()
|
|
89
|
+
_save()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def undo_last() -> str:
|
|
93
|
+
"""Undo the last file operation. Returns status message."""
|
|
94
|
+
entry = pop()
|
|
95
|
+
if not entry:
|
|
96
|
+
return "Nothing to undo."
|
|
97
|
+
|
|
98
|
+
path = Path(entry.file_path)
|
|
99
|
+
if not path.exists() and entry.original_content is None:
|
|
100
|
+
return f"Cannot undo: {entry.file_path} no longer exists (was created)"
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
if entry.original_content is None:
|
|
104
|
+
path.unlink()
|
|
105
|
+
return f"Undone: deleted {entry.file_path} (was created by {entry.action})"
|
|
106
|
+
else:
|
|
107
|
+
path.write_text(entry.original_content, encoding="utf-8")
|
|
108
|
+
return f"Undone: restored {entry.file_path} to before {entry.action}"
|
|
109
|
+
except Exception as e:
|
|
110
|
+
return f"Undo failed: {e}"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def get_history() -> list[dict]:
|
|
114
|
+
"""Return readable history of undo entries."""
|
|
115
|
+
return [
|
|
116
|
+
{"file": e.file_path, "action": e.action}
|
|
117
|
+
for e in reversed(_undo_stack[-20:])
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def count() -> int:
|
|
122
|
+
return len(_undo_stack)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# Load existing history on import
|
|
126
|
+
_load()
|
luckyd_code/update.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Auto-update module."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
|
|
5
|
+
def get_version() -> str:
|
|
6
|
+
from luckyd_code import __version__
|
|
7
|
+
return __version__
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def check_for_updates() -> str:
|
|
11
|
+
"""Check if updates are available via git fetch."""
|
|
12
|
+
try:
|
|
13
|
+
subprocess.run(
|
|
14
|
+
["git", "fetch", "--quiet"],
|
|
15
|
+
capture_output=True, text=True, timeout=30,
|
|
16
|
+
)
|
|
17
|
+
r = subprocess.run(
|
|
18
|
+
["git", "rev-list", "--count", "HEAD..origin/HEAD"],
|
|
19
|
+
capture_output=True, text=True, timeout=30,
|
|
20
|
+
)
|
|
21
|
+
if r.returncode == 0 and r.stdout.strip():
|
|
22
|
+
count = int(r.stdout.strip())
|
|
23
|
+
if count > 0:
|
|
24
|
+
return f"{count} commit(s) behind. Run `/update` to pull."
|
|
25
|
+
r = subprocess.run(
|
|
26
|
+
["git", "remote", "-v"],
|
|
27
|
+
capture_output=True, text=True, timeout=10,
|
|
28
|
+
)
|
|
29
|
+
if r.returncode == 0 and r.stdout.strip():
|
|
30
|
+
return "Up to date."
|
|
31
|
+
return "Not a git repository or no remote configured"
|
|
32
|
+
except Exception as e:
|
|
33
|
+
return f"Cannot check for updates: {e}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def do_update() -> str:
|
|
37
|
+
"""Pull latest changes from git."""
|
|
38
|
+
try:
|
|
39
|
+
# Only stash if there are actual uncommitted changes, so we don't
|
|
40
|
+
# pop a stash the user created manually.
|
|
41
|
+
status = subprocess.run(
|
|
42
|
+
["git", "status", "--porcelain"],
|
|
43
|
+
capture_output=True, text=True, timeout=15,
|
|
44
|
+
)
|
|
45
|
+
has_changes = bool(status.returncode == 0 and status.stdout.strip())
|
|
46
|
+
|
|
47
|
+
if has_changes:
|
|
48
|
+
subprocess.run(["git", "stash"], capture_output=True, text=True, timeout=30)
|
|
49
|
+
|
|
50
|
+
r = subprocess.run(
|
|
51
|
+
["git", "pull"],
|
|
52
|
+
capture_output=True, text=True, timeout=60,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
if has_changes:
|
|
56
|
+
subprocess.run(["git", "stash", "pop"], capture_output=True, text=True, timeout=30)
|
|
57
|
+
|
|
58
|
+
return r.stdout.strip() or r.stderr.strip() or "Updated successfully"
|
|
59
|
+
except Exception as e:
|
|
60
|
+
return f"Update failed: {e}"
|