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.
Files changed (127) hide show
  1. luckyd_code/__init__.py +54 -0
  2. luckyd_code/__main__.py +5 -0
  3. luckyd_code/_agent_loop.py +551 -0
  4. luckyd_code/_data_dir.py +73 -0
  5. luckyd_code/agent.py +38 -0
  6. luckyd_code/analytics/__init__.py +18 -0
  7. luckyd_code/analytics/reporter.py +195 -0
  8. luckyd_code/analytics/scanner.py +443 -0
  9. luckyd_code/analytics/smells.py +316 -0
  10. luckyd_code/analytics/trends.py +303 -0
  11. luckyd_code/api.py +473 -0
  12. luckyd_code/audit_daemon.py +845 -0
  13. luckyd_code/autonomous_fixer.py +473 -0
  14. luckyd_code/background.py +159 -0
  15. luckyd_code/backup.py +237 -0
  16. luckyd_code/brain/__init__.py +84 -0
  17. luckyd_code/brain/assembler.py +100 -0
  18. luckyd_code/brain/chunker.py +345 -0
  19. luckyd_code/brain/constants.py +73 -0
  20. luckyd_code/brain/embedder.py +163 -0
  21. luckyd_code/brain/graph.py +311 -0
  22. luckyd_code/brain/indexer.py +316 -0
  23. luckyd_code/brain/parser.py +140 -0
  24. luckyd_code/brain/retriever.py +234 -0
  25. luckyd_code/cli.py +894 -0
  26. luckyd_code/cli_commands/__init__.py +1 -0
  27. luckyd_code/cli_commands/audit.py +120 -0
  28. luckyd_code/cli_commands/background.py +83 -0
  29. luckyd_code/cli_commands/brain.py +87 -0
  30. luckyd_code/cli_commands/config.py +75 -0
  31. luckyd_code/cli_commands/dispatcher.py +695 -0
  32. luckyd_code/cli_commands/sessions.py +41 -0
  33. luckyd_code/cli_entry.py +147 -0
  34. luckyd_code/cli_utils.py +112 -0
  35. luckyd_code/config.py +205 -0
  36. luckyd_code/context.py +214 -0
  37. luckyd_code/cost_tracker.py +209 -0
  38. luckyd_code/error_reporter.py +508 -0
  39. luckyd_code/exceptions.py +39 -0
  40. luckyd_code/export.py +126 -0
  41. luckyd_code/feedback_analyzer.py +290 -0
  42. luckyd_code/file_watcher.py +258 -0
  43. luckyd_code/git/__init__.py +11 -0
  44. luckyd_code/git/auto_commit.py +157 -0
  45. luckyd_code/git/tools.py +85 -0
  46. luckyd_code/hooks.py +236 -0
  47. luckyd_code/indexer.py +280 -0
  48. luckyd_code/init.py +39 -0
  49. luckyd_code/keybindings.py +77 -0
  50. luckyd_code/log.py +55 -0
  51. luckyd_code/mcp/__init__.py +6 -0
  52. luckyd_code/mcp/client.py +184 -0
  53. luckyd_code/memory/__init__.py +19 -0
  54. luckyd_code/memory/manager.py +339 -0
  55. luckyd_code/metrics/__init__.py +5 -0
  56. luckyd_code/model_registry.py +131 -0
  57. luckyd_code/orchestrator.py +204 -0
  58. luckyd_code/permissions/__init__.py +1 -0
  59. luckyd_code/permissions/manager.py +103 -0
  60. luckyd_code/planner.py +361 -0
  61. luckyd_code/plugins.py +91 -0
  62. luckyd_code/py.typed +0 -0
  63. luckyd_code/retry.py +57 -0
  64. luckyd_code/router.py +417 -0
  65. luckyd_code/sandbox.py +156 -0
  66. luckyd_code/self_critique.py +2 -0
  67. luckyd_code/self_improve.py +274 -0
  68. luckyd_code/sessions.py +114 -0
  69. luckyd_code/settings.py +72 -0
  70. luckyd_code/skills/__init__.py +8 -0
  71. luckyd_code/skills/review.py +22 -0
  72. luckyd_code/skills/security.py +17 -0
  73. luckyd_code/tasks/__init__.py +1 -0
  74. luckyd_code/tasks/manager.py +102 -0
  75. luckyd_code/templates/icon-192.png +0 -0
  76. luckyd_code/templates/icon-512.png +0 -0
  77. luckyd_code/templates/index.html +1965 -0
  78. luckyd_code/templates/manifest.json +14 -0
  79. luckyd_code/templates/src/app.js +694 -0
  80. luckyd_code/templates/src/body.html +767 -0
  81. luckyd_code/templates/src/cdn.txt +2 -0
  82. luckyd_code/templates/src/style.css +474 -0
  83. luckyd_code/templates/sw.js +31 -0
  84. luckyd_code/templates/test.html +6 -0
  85. luckyd_code/themes.py +48 -0
  86. luckyd_code/tools/__init__.py +97 -0
  87. luckyd_code/tools/agent_tools.py +65 -0
  88. luckyd_code/tools/bash.py +360 -0
  89. luckyd_code/tools/brain_tools.py +137 -0
  90. luckyd_code/tools/browser.py +369 -0
  91. luckyd_code/tools/datetime_tool.py +34 -0
  92. luckyd_code/tools/dockerfile_gen.py +212 -0
  93. luckyd_code/tools/file_ops.py +381 -0
  94. luckyd_code/tools/game_gen.py +360 -0
  95. luckyd_code/tools/git_tools.py +130 -0
  96. luckyd_code/tools/git_worktree.py +63 -0
  97. luckyd_code/tools/path_validate.py +64 -0
  98. luckyd_code/tools/project_gen.py +187 -0
  99. luckyd_code/tools/readme_gen.py +227 -0
  100. luckyd_code/tools/registry.py +157 -0
  101. luckyd_code/tools/shell_detect.py +109 -0
  102. luckyd_code/tools/web.py +89 -0
  103. luckyd_code/tools/youtube.py +187 -0
  104. luckyd_code/tools_bridge.py +144 -0
  105. luckyd_code/undo.py +126 -0
  106. luckyd_code/update.py +60 -0
  107. luckyd_code/verify.py +360 -0
  108. luckyd_code/web_app.py +176 -0
  109. luckyd_code/web_routes/__init__.py +23 -0
  110. luckyd_code/web_routes/background.py +73 -0
  111. luckyd_code/web_routes/brain.py +109 -0
  112. luckyd_code/web_routes/cost.py +12 -0
  113. luckyd_code/web_routes/files.py +133 -0
  114. luckyd_code/web_routes/memories.py +94 -0
  115. luckyd_code/web_routes/misc.py +67 -0
  116. luckyd_code/web_routes/project.py +48 -0
  117. luckyd_code/web_routes/review.py +20 -0
  118. luckyd_code/web_routes/sessions.py +44 -0
  119. luckyd_code/web_routes/settings.py +43 -0
  120. luckyd_code/web_routes/static.py +70 -0
  121. luckyd_code/web_routes/update.py +19 -0
  122. luckyd_code/web_routes/ws.py +237 -0
  123. luckyd_code-1.2.2.dist-info/METADATA +297 -0
  124. luckyd_code-1.2.2.dist-info/RECORD +127 -0
  125. luckyd_code-1.2.2.dist-info/WHEEL +4 -0
  126. luckyd_code-1.2.2.dist-info/entry_points.txt +3 -0
  127. luckyd_code-1.2.2.dist-info/licenses/LICENSE +21 -0
@@ -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}"