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
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Auto-commit — stage and commit agent-modified files after each completed turn.
|
|
2
|
+
|
|
3
|
+
Behaviour
|
|
4
|
+
---------
|
|
5
|
+
* Only runs when inside a git repository.
|
|
6
|
+
* Only commits files that the agent actually wrote or edited this turn.
|
|
7
|
+
* Skips the commit if there are no staged changes (e.g. the write was a no-op).
|
|
8
|
+
* Commit message is derived from the user's prompt (first 72 chars) so the
|
|
9
|
+
git log stays readable — same approach as Aider.
|
|
10
|
+
* Can be disabled globally with: /config set auto_commit false
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import subprocess
|
|
14
|
+
|
|
15
|
+
from ..log import get_logger
|
|
16
|
+
|
|
17
|
+
# Tool names whose arguments contain the path of a file that was changed.
|
|
18
|
+
# The argument key that holds the path is listed alongside.
|
|
19
|
+
_WRITE_TOOLS: dict[str, str] = {
|
|
20
|
+
"Write": "file_path",
|
|
21
|
+
"Edit": "file_path",
|
|
22
|
+
"MultiEdit": "file_path",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
_logger = get_logger()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _in_git_repo(cwd: str | None = None) -> bool:
|
|
29
|
+
"""Return True if cwd (or the process cwd) is inside a git repository."""
|
|
30
|
+
try:
|
|
31
|
+
r = subprocess.run(
|
|
32
|
+
["git", "rev-parse", "--is-inside-work-tree"],
|
|
33
|
+
capture_output=True, text=True, timeout=5,
|
|
34
|
+
cwd=cwd,
|
|
35
|
+
)
|
|
36
|
+
return r.returncode == 0
|
|
37
|
+
except Exception:
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _stage_files(paths: list[str], cwd: str | None = None) -> bool:
|
|
42
|
+
"""Stage specific files. Returns True if at least one file was staged."""
|
|
43
|
+
if not paths:
|
|
44
|
+
return False
|
|
45
|
+
try:
|
|
46
|
+
r = subprocess.run(
|
|
47
|
+
["git", "add", "--"] + paths,
|
|
48
|
+
capture_output=True, text=True, timeout=10,
|
|
49
|
+
cwd=cwd,
|
|
50
|
+
)
|
|
51
|
+
if r.returncode != 0:
|
|
52
|
+
_logger.warning("git add failed: %s", r.stderr.strip())
|
|
53
|
+
return False
|
|
54
|
+
return True
|
|
55
|
+
except Exception as e:
|
|
56
|
+
_logger.warning("git add error: %s", e)
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _has_staged_changes(cwd: str | None = None) -> bool:
|
|
61
|
+
"""Return True if there is anything in the index to commit."""
|
|
62
|
+
try:
|
|
63
|
+
r = subprocess.run(
|
|
64
|
+
["git", "diff", "--cached", "--quiet"],
|
|
65
|
+
capture_output=True, timeout=5,
|
|
66
|
+
cwd=cwd,
|
|
67
|
+
)
|
|
68
|
+
# exit 1 means differences exist
|
|
69
|
+
return r.returncode == 1
|
|
70
|
+
except Exception:
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _make_commit_message(user_prompt: str) -> str:
|
|
75
|
+
"""Build a short, readable commit message from the user's prompt."""
|
|
76
|
+
first_line = user_prompt.strip().splitlines()[0] if user_prompt.strip() else "agent changes"
|
|
77
|
+
# Truncate to 72 chars (git convention)
|
|
78
|
+
subject = first_line[:72].rstrip()
|
|
79
|
+
return f"agent: {subject}"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _commit(message: str, cwd: str | None = None) -> str | None:
|
|
83
|
+
"""Create a commit. Returns the short SHA on success, None on failure."""
|
|
84
|
+
try:
|
|
85
|
+
r = subprocess.run(
|
|
86
|
+
["git", "commit", "-m", message],
|
|
87
|
+
capture_output=True, text=True, timeout=15,
|
|
88
|
+
cwd=cwd,
|
|
89
|
+
)
|
|
90
|
+
if r.returncode != 0:
|
|
91
|
+
_logger.warning("git commit failed: %s", r.stderr.strip())
|
|
92
|
+
return None
|
|
93
|
+
# Parse the short SHA from the output line like "[main abc1234] ..."
|
|
94
|
+
for line in r.stdout.splitlines():
|
|
95
|
+
if line.startswith("["):
|
|
96
|
+
parts = line.split()
|
|
97
|
+
if len(parts) >= 2:
|
|
98
|
+
return parts[1].rstrip("]")
|
|
99
|
+
return "ok"
|
|
100
|
+
except Exception as e:
|
|
101
|
+
_logger.warning("git commit error: %s", e)
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def collect_modified_paths(tool_calls: list[dict], tool_args_map: dict[str, dict]) -> list[str]:
|
|
106
|
+
"""Extract file paths that were written or edited this turn.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
tool_calls: List of tool-call dicts (id, function.name, function.arguments).
|
|
110
|
+
tool_args_map: Mapping of tool_call_id → parsed args dict (populated during execution).
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
Deduplicated list of absolute/relative file path strings.
|
|
114
|
+
"""
|
|
115
|
+
seen: set[str] = set()
|
|
116
|
+
paths: list[str] = []
|
|
117
|
+
for tc in tool_calls:
|
|
118
|
+
name = tc.get("function", {}).get("name", "")
|
|
119
|
+
arg_key = _WRITE_TOOLS.get(name)
|
|
120
|
+
if not arg_key:
|
|
121
|
+
continue
|
|
122
|
+
args = tool_args_map.get(tc.get("id", ""), {})
|
|
123
|
+
fp = args.get(arg_key, "")
|
|
124
|
+
if fp and fp not in seen:
|
|
125
|
+
seen.add(fp)
|
|
126
|
+
paths.append(fp)
|
|
127
|
+
return paths
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def auto_commit(
|
|
131
|
+
user_prompt: str,
|
|
132
|
+
modified_paths: list[str],
|
|
133
|
+
cwd: str | None = None,
|
|
134
|
+
enabled: bool = True,
|
|
135
|
+
) -> str | None:
|
|
136
|
+
"""Stage modified_paths and commit if inside a git repo and enabled.
|
|
137
|
+
|
|
138
|
+
Returns the short commit SHA on success, None if skipped or failed.
|
|
139
|
+
"""
|
|
140
|
+
if not enabled or not modified_paths:
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
if not _in_git_repo(cwd):
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
# Only stage the files the agent actually touched
|
|
147
|
+
if not _stage_files(modified_paths, cwd):
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
if not _has_staged_changes(cwd):
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
message = _make_commit_message(user_prompt)
|
|
154
|
+
sha = _commit(message, cwd)
|
|
155
|
+
if sha:
|
|
156
|
+
_logger.info("Auto-committed %d file(s): %s [%s]", len(modified_paths), message, sha)
|
|
157
|
+
return sha
|
luckyd_code/git/tools.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def git_status() -> str:
|
|
5
|
+
try:
|
|
6
|
+
r = subprocess.run(["git", "status"], capture_output=True, text=True, timeout=30)
|
|
7
|
+
return r.stdout.strip() or r.stderr.strip()
|
|
8
|
+
except Exception as e:
|
|
9
|
+
return f"Error: {e}"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def git_diff(staged: bool = False) -> str:
|
|
13
|
+
try:
|
|
14
|
+
cmd = ["git", "diff", "--cached"] if staged else ["git", "diff"]
|
|
15
|
+
r = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
|
16
|
+
out = r.stdout.strip()
|
|
17
|
+
if not out:
|
|
18
|
+
return "No changes"
|
|
19
|
+
return out[:5000]
|
|
20
|
+
except Exception as e:
|
|
21
|
+
return f"Error: {e}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def git_log(count: int = 10) -> str:
|
|
25
|
+
try:
|
|
26
|
+
r = subprocess.run(
|
|
27
|
+
["git", "log", f"--max-count={count}", "--oneline"],
|
|
28
|
+
capture_output=True, text=True, timeout=30,
|
|
29
|
+
)
|
|
30
|
+
return r.stdout.strip() or r.stderr.strip()
|
|
31
|
+
except Exception as e:
|
|
32
|
+
return f"Error: {e}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def git_commit(message: str) -> str:
|
|
36
|
+
try:
|
|
37
|
+
r = subprocess.run(
|
|
38
|
+
["git", "commit", "-m", message],
|
|
39
|
+
capture_output=True, text=True, timeout=30,
|
|
40
|
+
)
|
|
41
|
+
return r.stdout.strip() or r.stderr.strip()
|
|
42
|
+
except Exception as e:
|
|
43
|
+
return f"Error: {e}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def git_add(files: list[str] | None = None) -> str:
|
|
47
|
+
try:
|
|
48
|
+
cmd = ["git", "add"] + (files if files else ["-A"])
|
|
49
|
+
r = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
|
50
|
+
return r.stdout.strip() or r.stderr.strip() or "Staged"
|
|
51
|
+
except Exception as e:
|
|
52
|
+
return f"Error: {e}"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def git_branch() -> str:
|
|
56
|
+
try:
|
|
57
|
+
r = subprocess.run(
|
|
58
|
+
["git", "branch", "-a"],
|
|
59
|
+
capture_output=True, text=True, timeout=30,
|
|
60
|
+
)
|
|
61
|
+
return r.stdout.strip() or r.stderr.strip()
|
|
62
|
+
except Exception as e:
|
|
63
|
+
return f"Error: {e}"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def git_create_pr(title: str, body: str = "", draft: bool = True) -> str:
|
|
67
|
+
try:
|
|
68
|
+
cmd = ["gh", "pr", "create", "--title", title, "--body", body]
|
|
69
|
+
if draft:
|
|
70
|
+
cmd.append("--draft")
|
|
71
|
+
r = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
|
72
|
+
return r.stdout.strip() or r.stderr.strip()
|
|
73
|
+
except Exception as e:
|
|
74
|
+
return f"Error: {e}"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def git_push(branch: str | None = None) -> str:
|
|
78
|
+
try:
|
|
79
|
+
cmd = ["git", "push", "-u", "origin"]
|
|
80
|
+
if branch:
|
|
81
|
+
cmd.append(branch)
|
|
82
|
+
r = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
|
83
|
+
return r.stdout.strip() or r.stderr.strip()
|
|
84
|
+
except Exception as e:
|
|
85
|
+
return f"Error: {e}"
|
luckyd_code/hooks.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Lifecycle hooks system — pre/post tool use, pre/post chat, session events.
|
|
2
|
+
|
|
3
|
+
Hooks are shell scripts defined in .deepseek-code/settings.local.json:
|
|
4
|
+
|
|
5
|
+
{
|
|
6
|
+
"hooks": {
|
|
7
|
+
"preToolUse": {
|
|
8
|
+
"script": "echo 'About to run $DSC_TOOL_NAME'",
|
|
9
|
+
"tools": ["all"]
|
|
10
|
+
},
|
|
11
|
+
"postToolUse": "npm run lint-changed",
|
|
12
|
+
"preChat": "echo 'Sending request to $DSC_MODEL'",
|
|
13
|
+
"postChat": "echo 'Response received'",
|
|
14
|
+
"onSessionStart": "echo 'Session started at $DSC_TIME'",
|
|
15
|
+
"onSessionEnd": "echo 'Session ended'"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
Hooks can return JSON on their first line to control execution:
|
|
20
|
+
{"allow": false} — block the tool call (preToolUse only)
|
|
21
|
+
{"env": {"K": "v"}} — update environment variables
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Optional
|
|
31
|
+
|
|
32
|
+
from .settings import load_settings, get_settings_dir
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
HOOK_EVENTS = [
|
|
36
|
+
"preToolUse",
|
|
37
|
+
"postToolUse",
|
|
38
|
+
"preChat",
|
|
39
|
+
"postChat",
|
|
40
|
+
"onSessionStart",
|
|
41
|
+
"onSessionEnd",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class HookResult:
|
|
47
|
+
success: bool = True
|
|
48
|
+
output: str = ""
|
|
49
|
+
error: Optional[str] = None
|
|
50
|
+
allow: bool = True # preToolUse: block or allow the tool call
|
|
51
|
+
env_updates: dict = field(default_factory=dict)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class HookRunner:
|
|
55
|
+
"""Execute shell-based hooks for lifecycle events."""
|
|
56
|
+
|
|
57
|
+
def __init__(self):
|
|
58
|
+
self.settings = load_settings()
|
|
59
|
+
|
|
60
|
+
def run_hook(self, event: str, context: Optional[dict] = None) -> list[HookResult]:
|
|
61
|
+
"""Run all hooks configured for an event.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
event: One of HOOK_EVENTS.
|
|
65
|
+
context: Dict of extra env vars to pass (e.g. tool_name, tool_args).
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
List of HookResult, one per hook script.
|
|
69
|
+
"""
|
|
70
|
+
if event not in HOOK_EVENTS:
|
|
71
|
+
return [HookResult(success=False, error=f"Unknown hook event: {event}")]
|
|
72
|
+
|
|
73
|
+
scripts = self._get_hook_scripts(event)
|
|
74
|
+
results = []
|
|
75
|
+
for hook_cfg in scripts:
|
|
76
|
+
script = hook_cfg.get("script", "")
|
|
77
|
+
if not script:
|
|
78
|
+
continue
|
|
79
|
+
# Check tool filter if present
|
|
80
|
+
tool_filter = hook_cfg.get("tools", ["all"])
|
|
81
|
+
if "all" not in tool_filter:
|
|
82
|
+
tool_name = (context or {}).get("tool_name", "")
|
|
83
|
+
if tool_name not in tool_filter:
|
|
84
|
+
continue
|
|
85
|
+
result = self._execute_script(script, event, context)
|
|
86
|
+
results.append(result)
|
|
87
|
+
return results
|
|
88
|
+
|
|
89
|
+
def _get_hook_scripts(self, event: str) -> list[dict]:
|
|
90
|
+
"""Get all hook scripts for an event from settings.
|
|
91
|
+
|
|
92
|
+
Returns empty for unknown (not in HOOK_EVENTS) event types
|
|
93
|
+
even if hooks are configured — prevents misconfiguration.
|
|
94
|
+
"""
|
|
95
|
+
if event not in HOOK_EVENTS:
|
|
96
|
+
return []
|
|
97
|
+
hooks = self.settings.get("hooks", {})
|
|
98
|
+
raw = hooks.get(event, "")
|
|
99
|
+
if isinstance(raw, str):
|
|
100
|
+
return [{"script": raw, "tools": ["all"]}] if raw else []
|
|
101
|
+
elif isinstance(raw, dict):
|
|
102
|
+
# Single hook config
|
|
103
|
+
if "script" in raw:
|
|
104
|
+
return [raw]
|
|
105
|
+
# Multiple hook configs keyed by name
|
|
106
|
+
return list(raw.values())
|
|
107
|
+
elif isinstance(raw, list):
|
|
108
|
+
return raw
|
|
109
|
+
return []
|
|
110
|
+
|
|
111
|
+
def _execute_script(self, script: str, event: str,
|
|
112
|
+
context: Optional[dict] = None) -> HookResult:
|
|
113
|
+
"""Execute a single hook script and return the result.
|
|
114
|
+
|
|
115
|
+
Supports both shell commands and ``.py`` scripts. Python scripts
|
|
116
|
+
are launched via ``sys.executable`` with the same environment
|
|
117
|
+
variables, so they work portably across platforms.
|
|
118
|
+
"""
|
|
119
|
+
env = {
|
|
120
|
+
"DSC_HOOK_EVENT": event,
|
|
121
|
+
"DSC_PROJECT_DIR": str(get_settings_dir().parent),
|
|
122
|
+
"DSC_TIME": __import__("datetime").datetime.now().isoformat(),
|
|
123
|
+
}
|
|
124
|
+
if context:
|
|
125
|
+
for k, v in context.items():
|
|
126
|
+
env[f"DSC_{k.upper()}"] = str(v)
|
|
127
|
+
|
|
128
|
+
full_env = {**os.environ, **env}
|
|
129
|
+
|
|
130
|
+
# Detect Python hook scripts
|
|
131
|
+
script_path = Path(script)
|
|
132
|
+
if script_path.suffix == ".py":
|
|
133
|
+
return self._run_python_script(script_path, event, full_env)
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
proc = subprocess.run(
|
|
137
|
+
script,
|
|
138
|
+
shell=True,
|
|
139
|
+
capture_output=True,
|
|
140
|
+
text=True,
|
|
141
|
+
timeout=30,
|
|
142
|
+
env=full_env,
|
|
143
|
+
)
|
|
144
|
+
output = proc.stdout.strip()
|
|
145
|
+
if proc.returncode != 0 and proc.stderr.strip():
|
|
146
|
+
return HookResult(
|
|
147
|
+
success=False,
|
|
148
|
+
error=proc.stderr.strip(),
|
|
149
|
+
output=output,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# Parse optional JSON directive from first line
|
|
153
|
+
if output:
|
|
154
|
+
first_line = output.split("\n")[0]
|
|
155
|
+
if first_line.startswith("{"):
|
|
156
|
+
try:
|
|
157
|
+
data = json.loads(first_line)
|
|
158
|
+
output = "\n".join(output.split("\n")[1:])
|
|
159
|
+
return HookResult(
|
|
160
|
+
success=True,
|
|
161
|
+
output=output,
|
|
162
|
+
allow=data.get("allow", True),
|
|
163
|
+
env_updates=data.get("env", {}),
|
|
164
|
+
)
|
|
165
|
+
except json.JSONDecodeError:
|
|
166
|
+
pass
|
|
167
|
+
|
|
168
|
+
return HookResult(success=True, output=output)
|
|
169
|
+
|
|
170
|
+
except subprocess.TimeoutExpired:
|
|
171
|
+
return HookResult(success=False, error=f"Hook '{event}' timed out after 30s")
|
|
172
|
+
except FileNotFoundError:
|
|
173
|
+
return HookResult(success=False, error=f"Hook '{event}' command not found: {script[:100]}")
|
|
174
|
+
except Exception as e:
|
|
175
|
+
return HookResult(success=False, error=f"Hook '{event}' error: {e}")
|
|
176
|
+
|
|
177
|
+
def _run_python_script(self, script_path: Path, event: str,
|
|
178
|
+
full_env: dict) -> HookResult:
|
|
179
|
+
"""Execute a ``.py`` hook script with the current Python interpreter.
|
|
180
|
+
|
|
181
|
+
The script receives all ``DSC_*`` environment variables and can
|
|
182
|
+
return JSON on its first stdout line for execution control
|
|
183
|
+
(same protocol as shell hooks).
|
|
184
|
+
"""
|
|
185
|
+
# Merge with os.environ so the child process inherits essential
|
|
186
|
+
# system variables (PATH, SYSTEMROOT, etc.) – required on Windows.
|
|
187
|
+
merged_env = {**os.environ, **full_env}
|
|
188
|
+
try:
|
|
189
|
+
proc = subprocess.run(
|
|
190
|
+
[sys.executable, str(script_path)],
|
|
191
|
+
capture_output=True,
|
|
192
|
+
text=True,
|
|
193
|
+
timeout=30,
|
|
194
|
+
env=merged_env,
|
|
195
|
+
)
|
|
196
|
+
output = proc.stdout.strip()
|
|
197
|
+
if proc.returncode != 0 and proc.stderr.strip():
|
|
198
|
+
return HookResult(
|
|
199
|
+
success=False,
|
|
200
|
+
error=proc.stderr.strip(),
|
|
201
|
+
output=output,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
# Parse optional JSON directive from first line
|
|
205
|
+
if output:
|
|
206
|
+
first_line = output.split("\n")[0]
|
|
207
|
+
if first_line.startswith("{"):
|
|
208
|
+
try:
|
|
209
|
+
data = json.loads(first_line)
|
|
210
|
+
output = "\n".join(output.split("\n")[1:])
|
|
211
|
+
return HookResult(
|
|
212
|
+
success=True,
|
|
213
|
+
output=output,
|
|
214
|
+
allow=data.get("allow", True),
|
|
215
|
+
env_updates=data.get("env", {}),
|
|
216
|
+
)
|
|
217
|
+
except json.JSONDecodeError:
|
|
218
|
+
pass
|
|
219
|
+
|
|
220
|
+
return HookResult(success=True, output=output)
|
|
221
|
+
|
|
222
|
+
except subprocess.TimeoutExpired:
|
|
223
|
+
return HookResult(success=False, error=f"Python hook '{script_path.name}' timed out after 30s")
|
|
224
|
+
except Exception as e:
|
|
225
|
+
return HookResult(success=False, error=f"Python hook '{script_path.name}' error: {e}")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# Global singleton access
|
|
229
|
+
_hook_runner: Optional[HookRunner] = None
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def get_hook_runner() -> HookRunner:
|
|
233
|
+
global _hook_runner
|
|
234
|
+
if _hook_runner is None:
|
|
235
|
+
_hook_runner = HookRunner()
|
|
236
|
+
return _hook_runner
|