cursor Init 完善多人协同changelog,以及godot相关基础skill和代码规范
This commit is contained in:
@@ -1,38 +1,61 @@
|
||||
# Cursor Hooks 说明
|
||||
|
||||
## 结构
|
||||
## 文件
|
||||
|
||||
```
|
||||
```text
|
||||
hooks/
|
||||
├── run-hook.ps1 # Windows dispatcher(读取 local-env.json 后分发)
|
||||
├── session-init.ps1 # sessionStart hook(PowerShell 版)
|
||||
├── session-init.sh # sessionStart hook(bash 版)
|
||||
├── check-changelog.ps1 # stop hook(PowerShell 版)
|
||||
├── check-changelog.sh # stop hook(bash 版)
|
||||
└── README.md # 本文件
|
||||
├── run-hook.ps1 # Windows dispatcher;读取 local-env.json 并校验 JSON 输出
|
||||
├── session-init.ps1 # sessionStart(PowerShell)
|
||||
├── session-init.sh # sessionStart(Bash)
|
||||
├── check-changelog.ps1 # stop / changelog guard(PowerShell)
|
||||
├── check-changelog.sh # stop / changelog guard(Bash)
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 跨平台适配
|
||||
## Windows 配置(当前模板)
|
||||
|
||||
`hooks.json` 中的 `command` 字段是**平台绑定的**——Windows 用 `powershell`,macOS/Linux 用 `bash`。
|
||||
`.cursor/hooks.json` 统一通过 dispatcher 调用:
|
||||
|
||||
### Windows(当前)
|
||||
- `sessionStart`:`powershell -ExecutionPolicy Bypass -File .cursor/hooks/run-hook.ps1 session-init`,超时 5 秒。
|
||||
- `stop`:`powershell -ExecutionPolicy Bypass -File .cursor/hooks/run-hook.ps1 check-changelog`,超时 10 秒,`loop_limit` 为 1。
|
||||
|
||||
hooks.json 使用 `powershell ... run-hook.ps1` 作为入口,dispatcher 根据
|
||||
`.cursor/local-env.json` 的 `shell` 字段决定执行 `.ps1` 还是 `.sh` 脚本。
|
||||
dispatcher 从 `.cursor/local-env.json` 读取 `shell`:
|
||||
|
||||
### 迁移到 macOS / Linux
|
||||
- `powershell` / `pwsh`:运行同名 `.ps1`。
|
||||
- `bash` / `zsh` / `sh`:选择同名 `.sh`,并通过 `bash` 执行。
|
||||
|
||||
将 hooks.json 的 command 改为直接调用 `.sh` 脚本:
|
||||
stdin 以 UTF-8 原样转发。子脚本成功时,dispatcher 校验并规范化其 JSON 对象;脚本缺失、配置无效、启动失败、非零退出或输出无效时,只向 stderr 写诊断,并向 hook 返回非阻断的 `{}`。
|
||||
|
||||
## macOS / Linux 切换
|
||||
|
||||
macOS/Linux 不需要 Windows dispatcher。将 `.cursor/hooks.json` 改为 Bash 直调:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"hooks": {
|
||||
"sessionStart": [{ "command": "bash .cursor/hooks/session-init.sh", "timeout": 5 }],
|
||||
"stop": [{ "command": "bash .cursor/hooks/check-changelog.sh", "timeout": 10, "loop_limit": 1 }]
|
||||
"sessionStart": [
|
||||
{
|
||||
"command": "bash .cursor/hooks/session-init.sh",
|
||||
"timeout": 5
|
||||
}
|
||||
],
|
||||
"stop": [
|
||||
{
|
||||
"command": "bash .cursor/hooks/check-changelog.sh",
|
||||
"timeout": 10,
|
||||
"loop_limit": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
同时更新 `.cursor/local-env.json` 的 `shell` 字段为 `bash` 或 `zsh`。
|
||||
同时把本机 `.cursor/local-env.json` 的 `shell` 设为 `bash`(或实际使用的 `zsh`)。Bash 脚本不依赖 `jq`;优先用 `python3`(Windows Git Bash 可回退 `python`)安全解析/生成 JSON,解释器缺失或解析失败时 fail-open 返回 `{}`。
|
||||
|
||||
## 行为
|
||||
|
||||
- `session-init` 清除上一会话的 `.cursor/changelog/.changelog-ack`,并刷新 gitignored 的 `.cursor/changelog/.session-start`。非 `agent` 模式通过 hook `env` 设置 `CURSOR_SKIP_CHANGELOG=1`。
|
||||
- Cursor 的 `stop` 输入不包含可靠的工具编辑历史,因此 `check-changelog` 不解析不存在的“编辑证据”。它只在未 ack、未被 session 环境跳过,且 Godot 源码/配置同时晚于本会话起点与当前 changelog 时返回 `followup_message`,避免旧会话残留 mtime 误报。
|
||||
- 扫描从仓库根开始,覆盖 `project.godot`、`.gd`、`.tscn`、`.tres`、`.res`、`.gdshader`、`.cfg`、`.json`、`.cs`、`.csproj` 及常用工具脚本;跳过 `.git`、`.cursor`、`.godot`、依赖目录、Python 缓存/虚拟环境和常见构建输出目录。
|
||||
- 所有正常与失败路径都在 stdout 输出一个合法 JSON 对象;检查失败默认不阻断。
|
||||
|
||||
@@ -1,105 +1,124 @@
|
||||
# Changelog sync guard — 统一 stop hook
|
||||
#
|
||||
# 用确定性逻辑检查:
|
||||
# 1. 源文件是否比 changelog 更新(mtime 比较)
|
||||
# 2. stdin 中的 agent 上下文是否显示有源文件编辑操作
|
||||
#
|
||||
# 静默条件(不触发提醒):
|
||||
# 1. 环境变量 CURSOR_SKIP_CHANGELOG 被设置(sessionStart 在非 agent 模式设置)
|
||||
# 2. stdin JSON 中 composer_mode 不是 "agent"(如 debug/ask/edit 模式)
|
||||
# 3. changelog 文件不存在
|
||||
# 4. .changelog-ack 标记文件存在且足够新(本会话已确认过 changelog 状态)
|
||||
# 5. 没有源文件比 changelog 更新
|
||||
# 6. stdin 上下文中没有文件编辑操作的证据(防止跨会话残留 mtime 误触发)
|
||||
# Changelog sync guard: compare Godot source/config mtimes against this session and changelog.
|
||||
$utf8 = New-Object System.Text.UTF8Encoding($false)
|
||||
[Console]::InputEncoding = $utf8
|
||||
[Console]::OutputEncoding = $utf8
|
||||
|
||||
$input = [Console]::In.ReadToEnd()
|
||||
function Write-EmptyHookResult {
|
||||
[Console]::Out.WriteLine('{}')
|
||||
}
|
||||
|
||||
# === 豁免检查 1:环境变量跳过标志 ===
|
||||
$null = [Console]::In.ReadToEnd()
|
||||
|
||||
# sessionStart sets this variable outside agent mode.
|
||||
if ($env:CURSOR_SKIP_CHANGELOG) {
|
||||
Write-Output '{}'
|
||||
Write-EmptyHookResult
|
||||
exit 0
|
||||
}
|
||||
|
||||
# === 豁免检查 2:从 stdin 解析 composer_mode ===
|
||||
try {
|
||||
$data = $input | ConvertFrom-Json
|
||||
$mode = $data.composer_mode
|
||||
if ($mode -and $mode -ne "agent") {
|
||||
Write-Output '{}'
|
||||
exit 0
|
||||
$cursorDir = Split-Path -Parent $PSScriptRoot
|
||||
$repoRoot = Split-Path -Parent $cursorDir
|
||||
$changelog = Join-Path $cursorDir 'changelog\changelog-headlines.md'
|
||||
$ackFile = Join-Path $cursorDir 'changelog\.changelog-ack'
|
||||
$sessionMarker = Join-Path $cursorDir 'changelog\.session-start'
|
||||
|
||||
if (-not (Test-Path -LiteralPath $changelog -PathType Leaf)) {
|
||||
Write-EmptyHookResult
|
||||
exit 0
|
||||
}
|
||||
|
||||
$changelogMtime = (Get-Item -LiteralPath $changelog).LastWriteTimeUtc
|
||||
if ((Test-Path -LiteralPath $ackFile -PathType Leaf) -and
|
||||
(Get-Item -LiteralPath $ackFile).LastWriteTimeUtc -ge $changelogMtime) {
|
||||
Write-EmptyHookResult
|
||||
exit 0
|
||||
}
|
||||
|
||||
$comparisonMtime = $changelogMtime
|
||||
if ((Test-Path -LiteralPath $sessionMarker -PathType Leaf)) {
|
||||
$sessionMtime = (Get-Item -LiteralPath $sessionMarker).LastWriteTimeUtc
|
||||
if ($sessionMtime -gt $comparisonMtime) {
|
||||
$comparisonMtime = $sessionMtime
|
||||
}
|
||||
} catch {
|
||||
# JSON 解析失败,继续后续检查
|
||||
}
|
||||
|
||||
# === 豁免检查 3:从 stdin 文本匹配 debug 上下文关键词 ===
|
||||
# stop hook 的 $ARGUMENTS 可能包含对话/工具上下文,检测 debug 相关信号
|
||||
if ($input -match '"mode"\s*:\s*"debug"' -or
|
||||
$input -match 'debug[\s_-]?mode' -or
|
||||
$input -match 'Debug Mode') {
|
||||
Write-Output '{}'
|
||||
exit 0
|
||||
}
|
||||
|
||||
$changelog = ".cursor\changelog\changelog-headlines.md"
|
||||
$srcDir = "art-agent"
|
||||
|
||||
# === 豁免检查 4:changelog 文件不存在 ===
|
||||
if (-not (Test-Path $changelog)) {
|
||||
Write-Output '{}'
|
||||
exit 0
|
||||
}
|
||||
|
||||
$clMtime = (Get-Item $changelog).LastWriteTime
|
||||
|
||||
# === 豁免检查 5:ack 标记文件足够新 ===
|
||||
$ackFile = ".cursor\changelog\.changelog-ack"
|
||||
if ((Test-Path $ackFile) -and (Get-Item $ackFile).LastWriteTime -ge $clMtime) {
|
||||
Write-Output '{}'
|
||||
exit 0
|
||||
}
|
||||
|
||||
# === 豁免检查 6:stdin 中无文件编辑证据 ===
|
||||
# 防止跨会话残留 mtime 差异导致误触发:如果 ack 文件不存在(或过旧),
|
||||
# 但 stdin 上下文中也没有任何文件写入/编辑操作的痕迹,说明本次会话
|
||||
# 没有进行代码改动,不应触发提醒。
|
||||
$hasEditEvidence = (
|
||||
$input -match 'StrReplace|Write\s*tool|edit_file|file_write|write_to_file' -or
|
||||
$input -match 'Created file|Modified file|Wrote contents' -or
|
||||
$input -match '"tool"\s*:\s*"(str_replace|write|edit)"'
|
||||
$includedNames = @('project.godot')
|
||||
$includedExtensions = @(
|
||||
'.gd',
|
||||
'.tscn',
|
||||
'.tres',
|
||||
'.res',
|
||||
'.gdshader',
|
||||
'.cfg',
|
||||
'.json',
|
||||
'.cs',
|
||||
'.csproj',
|
||||
'.py',
|
||||
'.ps1',
|
||||
'.sh'
|
||||
)
|
||||
$excludedDirectoryNames = @(
|
||||
'.git',
|
||||
'.cursor',
|
||||
'.godot',
|
||||
'node_modules',
|
||||
'__pycache__',
|
||||
'venv',
|
||||
'.venv',
|
||||
'dist',
|
||||
'build',
|
||||
'out',
|
||||
'bin',
|
||||
'obj',
|
||||
'export',
|
||||
'exports',
|
||||
'coverage',
|
||||
'tmp',
|
||||
'temp'
|
||||
)
|
||||
if (-not $hasEditEvidence) {
|
||||
Write-Output '{}'
|
||||
exit 0
|
||||
}
|
||||
|
||||
# === 核心检查:是否有源文件比 changelog 更新 ===
|
||||
$extensions = @("*.py", "*.tsx", "*.ts", "*.css")
|
||||
$excludeDirs = @("node_modules", ".next", "__pycache__", "venv")
|
||||
|
||||
$newerFile = $null
|
||||
foreach ($ext in $extensions) {
|
||||
$files = Get-ChildItem -Path $srcDir -Filter $ext -Recurse -ErrorAction SilentlyContinue |
|
||||
Where-Object {
|
||||
$skip = $false
|
||||
foreach ($ex in $excludeDirs) {
|
||||
if ($_.FullName -like "*\$ex\*") { $skip = $true; break }
|
||||
}
|
||||
-not $skip -and $_.LastWriteTime -gt $clMtime
|
||||
} |
|
||||
Select-Object -First 1
|
||||
$rootItem = Get-Item -LiteralPath $repoRoot
|
||||
$directories = New-Object 'System.Collections.Generic.Stack[System.IO.DirectoryInfo]'
|
||||
$directories.Push($rootItem)
|
||||
|
||||
if ($files) {
|
||||
$newerFile = $files.Name
|
||||
break
|
||||
while ($directories.Count -gt 0 -and -not $newerFile) {
|
||||
$directory = $directories.Pop()
|
||||
try {
|
||||
$entries = Get-ChildItem -LiteralPath $directory.FullName -Force -ErrorAction Stop
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
foreach ($entry in $entries) {
|
||||
if ($entry.PSIsContainer) {
|
||||
$isReparsePoint = ($entry.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0
|
||||
if (-not $isReparsePoint -and $excludedDirectoryNames -notcontains $entry.Name) {
|
||||
$directories.Push($entry)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
$isIncluded = ($includedNames -contains $entry.Name) -or
|
||||
($includedExtensions -contains $entry.Extension)
|
||||
if ($isIncluded -and $entry.LastWriteTimeUtc -gt $comparisonMtime) {
|
||||
$newerFile = $entry
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($newerFile) {
|
||||
$msg = "[Hook] Source file updated (e.g. $newerFile) but changelog not synced. Run dev-changelog Skill operation A NOW to write all three changelog layers."
|
||||
$json = '{"followup_message":"' + $msg.Replace('"','\"') + '"}'
|
||||
Write-Output $json
|
||||
$rootPrefix = $repoRoot.TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar
|
||||
$relativePath = if ($newerFile.FullName.StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
$newerFile.FullName.Substring($rootPrefix.Length)
|
||||
} else {
|
||||
$newerFile.Name
|
||||
}
|
||||
$message = "[Hook] Godot source/config is newer than the changelog (e.g. $relativePath). Run dev-changelog Skill operation A now: add a fragment under .cursor/changelog/entries/ and rebuild the changelog views."
|
||||
$result = [ordered]@{ followup_message = $message }
|
||||
[Console]::Out.WriteLine(($result | ConvertTo-Json -Compress -Depth 3))
|
||||
} else {
|
||||
Write-Output '{}'
|
||||
Write-EmptyHookResult
|
||||
}
|
||||
|
||||
exit 0
|
||||
|
||||
@@ -1,58 +1,132 @@
|
||||
#!/usr/bin/env bash
|
||||
# Changelog sync guard — stop hook (bash 版)
|
||||
# Changelog sync guard:比较 Godot 源码/配置与本会话起点、changelog 的 mtime。
|
||||
|
||||
input=$(cat)
|
||||
emit_empty() {
|
||||
printf '{}\n'
|
||||
}
|
||||
|
||||
# === 豁免检查 1:环境变量跳过标志 ===
|
||||
if [ -n "$CURSOR_SKIP_CHANGELOG" ]; then
|
||||
echo '{}'; exit 0
|
||||
cat >/dev/null
|
||||
|
||||
if [ -n "${CURSOR_SKIP_CHANGELOG:-}" ]; then
|
||||
emit_empty
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# === 豁免检查 2:从 stdin 解析 composer_mode ===
|
||||
mode=$(echo "$input" | python3 -c "import sys,json; print(json.load(sys.stdin).get('composer_mode',''))" 2>/dev/null || echo "")
|
||||
if [ -n "$mode" ] && [ "$mode" != "agent" ]; then
|
||||
echo '{}'; exit 0
|
||||
fi
|
||||
script_dir=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)
|
||||
cursor_dir=$(dirname -- "$script_dir")
|
||||
repo_root=$(dirname -- "$cursor_dir")
|
||||
changelog="$cursor_dir/changelog/changelog-headlines.md"
|
||||
ack_file="$cursor_dir/changelog/.changelog-ack"
|
||||
session_marker="$cursor_dir/changelog/.session-start"
|
||||
|
||||
# === 豁免检查 3:从 stdin 文本匹配 debug 上下文关键词 ===
|
||||
if echo "$input" | grep -qiE '"mode"\s*:\s*"debug"|debug[\s_-]?mode|Debug Mode'; then
|
||||
echo '{}'; exit 0
|
||||
fi
|
||||
|
||||
changelog=".cursor/changelog/changelog-headlines.md"
|
||||
srcDir="art-agent"
|
||||
|
||||
# === 豁免检查 4:changelog 文件不存在 ===
|
||||
if [ ! -f "$changelog" ]; then
|
||||
echo '{}'; exit 0
|
||||
emit_empty
|
||||
exit 0
|
||||
fi
|
||||
|
||||
clMtime=$(stat -c %Y "$changelog" 2>/dev/null || stat -f %m "$changelog" 2>/dev/null)
|
||||
get_mtime() {
|
||||
stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null
|
||||
}
|
||||
|
||||
# === 豁免检查 5:ack 标记文件足够新 ===
|
||||
ackFile=".cursor/changelog/.changelog-ack"
|
||||
if [ -f "$ackFile" ]; then
|
||||
ackMtime=$(stat -c %Y "$ackFile" 2>/dev/null || stat -f %m "$ackFile" 2>/dev/null)
|
||||
if [ "$ackMtime" -ge "$clMtime" ] 2>/dev/null; then
|
||||
echo '{}'; exit 0
|
||||
if [ -f "$ack_file" ]; then
|
||||
changelog_mtime=$(get_mtime "$changelog")
|
||||
ack_mtime=$(get_mtime "$ack_file")
|
||||
if [ -n "$changelog_mtime" ] && [ -n "$ack_mtime" ] &&
|
||||
[ "$ack_mtime" -ge "$changelog_mtime" ] 2>/dev/null; then
|
||||
emit_empty
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# === 豁免检查 6:stdin 中无文件编辑证据 ===
|
||||
if ! echo "$input" | grep -qE 'StrReplace|Write\s*tool|edit_file|file_write|write_to_file|Created file|Modified file|Wrote contents|"tool"\s*:\s*"(str_replace|write|edit)"'; then
|
||||
echo '{}'; exit 0
|
||||
comparison_file=$changelog
|
||||
if [ -f "$session_marker" ]; then
|
||||
changelog_mtime=$(get_mtime "$changelog")
|
||||
session_mtime=$(get_mtime "$session_marker")
|
||||
if [ -n "$changelog_mtime" ] && [ -n "$session_mtime" ] &&
|
||||
[ "$session_mtime" -gt "$changelog_mtime" ] 2>/dev/null; then
|
||||
comparison_file=$session_marker
|
||||
fi
|
||||
fi
|
||||
|
||||
# === 核心检查:是否有源文件比 changelog 更新 ===
|
||||
newerFile=$(find "$srcDir" -type f \( -name "*.py" -o -name "*.tsx" -o -name "*.ts" -o -name "*.css" \) \
|
||||
! -path "*/node_modules/*" ! -path "*/.next/*" ! -path "*/__pycache__/*" ! -path "*/venv/*" \
|
||||
-newer "$changelog" -print -quit 2>/dev/null)
|
||||
newer_file=''
|
||||
while IFS= read -r candidate; do
|
||||
newer_file=$candidate
|
||||
break
|
||||
done < <(
|
||||
find "$repo_root" \
|
||||
\( -type d \( \
|
||||
-name '.git' -o \
|
||||
-name '.cursor' -o \
|
||||
-name '.godot' -o \
|
||||
-name 'node_modules' -o \
|
||||
-name '__pycache__' -o \
|
||||
-name 'venv' -o \
|
||||
-name '.venv' -o \
|
||||
-name 'dist' -o \
|
||||
-name 'build' -o \
|
||||
-name 'out' -o \
|
||||
-name 'bin' -o \
|
||||
-name 'obj' -o \
|
||||
-name 'export' -o \
|
||||
-name 'exports' -o \
|
||||
-name 'coverage' -o \
|
||||
-name 'tmp' -o \
|
||||
-name 'temp' \
|
||||
\) -prune \) -o \
|
||||
\( -type f \( \
|
||||
-iname 'project.godot' -o \
|
||||
-iname '*.gd' -o \
|
||||
-iname '*.tscn' -o \
|
||||
-iname '*.tres' -o \
|
||||
-iname '*.res' -o \
|
||||
-iname '*.gdshader' -o \
|
||||
-iname '*.cfg' -o \
|
||||
-iname '*.json' -o \
|
||||
-iname '*.cs' -o \
|
||||
-iname '*.csproj' -o \
|
||||
-iname '*.py' -o \
|
||||
-iname '*.ps1' -o \
|
||||
-iname '*.sh' \
|
||||
\) -newer "$comparison_file" -print \) 2>/dev/null
|
||||
)
|
||||
|
||||
if [ -n "$newerFile" ]; then
|
||||
fname=$(basename "$newerFile")
|
||||
msg="[Hook] Source file updated (e.g. $fname) but changelog not synced. Run dev-changelog Skill operation A NOW to write all three changelog layers."
|
||||
echo "{\"followup_message\":\"$msg\"}"
|
||||
if [ -z "$newer_file" ]; then
|
||||
emit_empty
|
||||
exit 0
|
||||
fi
|
||||
|
||||
relative_file=${newer_file#"$repo_root"/}
|
||||
message="[Hook] Godot source/config is newer than the changelog (e.g. $relative_file). Run dev-changelog Skill operation A now: add a fragment under .cursor/changelog/entries/ and rebuild the changelog views."
|
||||
|
||||
python_cmd=''
|
||||
for candidate in python3 python; do
|
||||
if command -v "$candidate" >/dev/null 2>&1 &&
|
||||
"$candidate" -c 'import json' >/dev/null 2>&1; then
|
||||
python_cmd=$candidate
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -n "$python_cmd" ]; then
|
||||
result=$(
|
||||
printf '%s' "$message" | "$python_cmd" -c '
|
||||
import json
|
||||
import sys
|
||||
|
||||
print(json.dumps(
|
||||
{"followup_message": sys.stdin.read()},
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
))
|
||||
' 2>/dev/null
|
||||
) || result='{}'
|
||||
if [ -n "$result" ]; then
|
||||
printf '%s\n' "$result"
|
||||
else
|
||||
emit_empty
|
||||
fi
|
||||
else
|
||||
echo '{}'
|
||||
emit_empty
|
||||
fi
|
||||
|
||||
exit 0
|
||||
|
||||
@@ -1,35 +1,111 @@
|
||||
# run-hook.ps1 — 通用 hook dispatcher
|
||||
# 从 .cursor/local-env.json 读取 shell 类型,决定执行 .ps1 还是 .sh 脚本
|
||||
# 用法:powershell -ExecutionPolicy Bypass -File .cursor/hooks/run-hook.ps1 <hook-name>
|
||||
# 示例:run-hook.ps1 session-init → 执行 session-init.ps1 或 session-init.sh
|
||||
|
||||
# Hook dispatcher: select the PowerShell or Bash implementation from local-env.json.
|
||||
param([string]$HookName)
|
||||
|
||||
$localEnvPath = ".cursor\local-env.json"
|
||||
$shell = "powershell"
|
||||
$utf8 = New-Object System.Text.UTF8Encoding($false)
|
||||
[Console]::InputEncoding = $utf8
|
||||
[Console]::OutputEncoding = $utf8
|
||||
|
||||
if (Test-Path $localEnvPath) {
|
||||
try {
|
||||
$config = Get-Content $localEnvPath -Raw | ConvertFrom-Json
|
||||
if ($config.shell) { $shell = $config.shell }
|
||||
} catch {}
|
||||
function Write-EmptyHookResult {
|
||||
[Console]::Out.WriteLine('{}')
|
||||
}
|
||||
|
||||
$hookDir = ".cursor\hooks"
|
||||
$input = [Console]::In.ReadToEnd()
|
||||
$inputPayload = [Console]::In.ReadToEnd()
|
||||
|
||||
if ($shell -eq "powershell") {
|
||||
$scriptPath = Join-Path $hookDir "$HookName.ps1"
|
||||
if (Test-Path $scriptPath) {
|
||||
$input | powershell -ExecutionPolicy Bypass -File $scriptPath
|
||||
} else {
|
||||
Write-Output '{}'
|
||||
try {
|
||||
if ([string]::IsNullOrWhiteSpace($HookName) -or $HookName -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]*$') {
|
||||
throw "Invalid hook name."
|
||||
}
|
||||
} else {
|
||||
$scriptPath = Join-Path $hookDir "$HookName.sh"
|
||||
if (Test-Path $scriptPath) {
|
||||
$input | bash $scriptPath
|
||||
} else {
|
||||
Write-Output '{}'
|
||||
|
||||
$cursorDir = Split-Path -Parent $PSScriptRoot
|
||||
$repoRoot = Split-Path -Parent $cursorDir
|
||||
$localEnvPath = Join-Path $cursorDir 'local-env.json'
|
||||
$shell = 'powershell'
|
||||
|
||||
if (Test-Path -LiteralPath $localEnvPath -PathType Leaf) {
|
||||
try {
|
||||
$config = Get-Content -LiteralPath $localEnvPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($config.shell -is [string] -and -not [string]::IsNullOrWhiteSpace($config.shell)) {
|
||||
$shell = $config.shell.Trim().ToLowerInvariant()
|
||||
}
|
||||
} catch {
|
||||
[Console]::Error.WriteLine("[run-hook] Ignoring invalid local-env.json: {0}", $_.Exception.Message)
|
||||
}
|
||||
}
|
||||
|
||||
$processInfo = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$processInfo.UseShellExecute = $false
|
||||
$processInfo.CreateNoWindow = $true
|
||||
$processInfo.RedirectStandardInput = $true
|
||||
$processInfo.RedirectStandardOutput = $true
|
||||
$processInfo.RedirectStandardError = $true
|
||||
$processInfo.WorkingDirectory = $repoRoot
|
||||
|
||||
if ($processInfo.PSObject.Properties.Name -contains 'StandardInputEncoding') {
|
||||
$processInfo.StandardInputEncoding = $utf8
|
||||
$processInfo.StandardOutputEncoding = $utf8
|
||||
$processInfo.StandardErrorEncoding = $utf8
|
||||
}
|
||||
|
||||
switch ($shell) {
|
||||
{ $_ -in @('powershell', 'powershell.exe', 'pwsh', 'pwsh.exe') } {
|
||||
$scriptPath = Join-Path $PSScriptRoot "$HookName.ps1"
|
||||
if (-not (Test-Path -LiteralPath $scriptPath -PathType Leaf)) {
|
||||
throw "PowerShell hook not found: $HookName"
|
||||
}
|
||||
$processInfo.FileName = if ($shell -in @('pwsh', 'pwsh.exe')) { 'pwsh' } else { 'powershell' }
|
||||
$processInfo.Arguments = "-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$scriptPath`""
|
||||
break
|
||||
}
|
||||
{ $_ -in @('bash', 'bash.exe', 'zsh', 'sh') } {
|
||||
$scriptPath = (Join-Path $PSScriptRoot "$HookName.sh") -replace '\\', '/'
|
||||
if (-not (Test-Path -LiteralPath $scriptPath -PathType Leaf)) {
|
||||
throw "Shell hook not found: $HookName"
|
||||
}
|
||||
$processInfo.FileName = 'bash'
|
||||
$processInfo.Arguments = "`"$scriptPath`""
|
||||
break
|
||||
}
|
||||
default {
|
||||
throw "Unsupported shell '$shell'."
|
||||
}
|
||||
}
|
||||
|
||||
$process = New-Object System.Diagnostics.Process
|
||||
$process.StartInfo = $processInfo
|
||||
if (-not $process.Start()) {
|
||||
throw "Unable to start hook process."
|
||||
}
|
||||
|
||||
$stdoutTask = $process.StandardOutput.ReadToEndAsync()
|
||||
$stderrTask = $process.StandardError.ReadToEndAsync()
|
||||
$process.StandardInput.Write($inputPayload)
|
||||
$process.StandardInput.Close()
|
||||
$process.WaitForExit()
|
||||
|
||||
$stdout = $stdoutTask.Result
|
||||
$stderr = $stderrTask.Result
|
||||
if (-not [string]::IsNullOrWhiteSpace($stderr)) {
|
||||
[Console]::Error.WriteLine($stderr.TrimEnd())
|
||||
}
|
||||
if ($process.ExitCode -ne 0) {
|
||||
throw "Hook exited with code $($process.ExitCode)."
|
||||
}
|
||||
|
||||
$trimmed = $stdout.Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($trimmed) -or -not $trimmed.StartsWith('{')) {
|
||||
throw "Hook returned an empty or non-object response."
|
||||
}
|
||||
|
||||
$result = $trimmed | ConvertFrom-Json
|
||||
$normalized = $result | ConvertTo-Json -Compress -Depth 20
|
||||
if ([string]::IsNullOrWhiteSpace($normalized) -or -not $normalized.StartsWith('{')) {
|
||||
throw "Hook response is not a JSON object."
|
||||
}
|
||||
|
||||
[Console]::Out.WriteLine($normalized)
|
||||
} catch {
|
||||
[Console]::Error.WriteLine("[run-hook] {0}", $_.Exception.Message)
|
||||
Write-EmptyHookResult
|
||||
}
|
||||
|
||||
exit 0
|
||||
|
||||
@@ -1,23 +1,48 @@
|
||||
# session-init.ps1 — 会话启动时检测 composer_mode,非 agent 模式设置跳过标志
|
||||
#
|
||||
# sessionStart input 包含 composer_mode 字段("agent" / "ask" / "edit" / "debug" 等)
|
||||
# 通过 env 输出的环境变量会传递给同会话内所有后续 hook
|
||||
# sessionStart: reset changelog runtime markers and skip checks outside agent mode.
|
||||
$utf8 = New-Object System.Text.UTF8Encoding($false)
|
||||
[Console]::InputEncoding = $utf8
|
||||
[Console]::OutputEncoding = $utf8
|
||||
|
||||
$input = [Console]::In.ReadToEnd()
|
||||
$inputPayload = [Console]::In.ReadToEnd()
|
||||
$mode = $null
|
||||
|
||||
try {
|
||||
$data = $input | ConvertFrom-Json
|
||||
$mode = $data.composer_mode
|
||||
if (-not [string]::IsNullOrWhiteSpace($inputPayload)) {
|
||||
$data = $inputPayload | ConvertFrom-Json
|
||||
if ($data.composer_mode -is [string]) {
|
||||
$mode = $data.composer_mode
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
$mode = $null
|
||||
}
|
||||
|
||||
Remove-Item ".cursor\changelog\.changelog-ack" -ErrorAction SilentlyContinue
|
||||
|
||||
if ($mode -and $mode -ne "agent") {
|
||||
$json = '{"env":{"CURSOR_SKIP_CHANGELOG":"1","CURSOR_COMPOSER_MODE":"' + $mode + '"}}'
|
||||
Write-Output $json
|
||||
} else {
|
||||
Write-Output '{}'
|
||||
$cursorDir = Split-Path -Parent $PSScriptRoot
|
||||
$ackFile = Join-Path $cursorDir 'changelog\.changelog-ack'
|
||||
$sessionMarker = Join-Path $cursorDir 'changelog\.session-start'
|
||||
Remove-Item -LiteralPath $ackFile -Force -ErrorAction SilentlyContinue
|
||||
try {
|
||||
$stream = [System.IO.File]::Open(
|
||||
$sessionMarker,
|
||||
[System.IO.FileMode]::Create,
|
||||
[System.IO.FileAccess]::Write,
|
||||
[System.IO.FileShare]::ReadWrite
|
||||
)
|
||||
$stream.Dispose()
|
||||
} catch {
|
||||
# Runtime marker failure must not block session startup.
|
||||
}
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($mode) -and $mode -ne 'agent') {
|
||||
$result = [ordered]@{
|
||||
env = [ordered]@{
|
||||
CURSOR_SKIP_CHANGELOG = '1'
|
||||
CURSOR_COMPOSER_MODE = $mode
|
||||
}
|
||||
}
|
||||
[Console]::Out.WriteLine(($result | ConvertTo-Json -Compress -Depth 4))
|
||||
} else {
|
||||
[Console]::Out.WriteLine('{}')
|
||||
}
|
||||
|
||||
exit 0
|
||||
|
||||
@@ -1,15 +1,53 @@
|
||||
#!/usr/bin/env bash
|
||||
# session-init.sh — 会话启动时检测 composer_mode,非 agent 模式设置跳过标志
|
||||
# sessionStart:重置 changelog 运行态标记,并在非 agent 模式下跳过检查。
|
||||
|
||||
input=$(cat)
|
||||
script_dir=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)
|
||||
cursor_dir=$(dirname -- "$script_dir")
|
||||
rm -f -- "$cursor_dir/changelog/.changelog-ack" 2>/dev/null
|
||||
: > "$cursor_dir/changelog/.session-start" 2>/dev/null || true
|
||||
|
||||
mode=$(echo "$input" | python3 -c "import sys,json; print(json.load(sys.stdin).get('composer_mode',''))" 2>/dev/null || echo "")
|
||||
python_cmd=''
|
||||
for candidate in python3 python; do
|
||||
if command -v "$candidate" >/dev/null 2>&1 &&
|
||||
"$candidate" -c 'import json' >/dev/null 2>&1; then
|
||||
python_cmd=$candidate
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
rm -f ".cursor/changelog/.changelog-ack"
|
||||
if [ -n "$python_cmd" ]; then
|
||||
result=$(
|
||||
printf '%s' "$input" | "$python_cmd" -c '
|
||||
import json
|
||||
import sys
|
||||
|
||||
if [ -n "$mode" ] && [ "$mode" != "agent" ]; then
|
||||
echo "{\"env\":{\"CURSOR_SKIP_CHANGELOG\":\"1\",\"CURSOR_COMPOSER_MODE\":\"$mode\"}}"
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
mode = data.get("composer_mode", "")
|
||||
if not isinstance(mode, str):
|
||||
mode = ""
|
||||
except Exception:
|
||||
mode = ""
|
||||
|
||||
if mode and mode != "agent":
|
||||
print(json.dumps({
|
||||
"env": {
|
||||
"CURSOR_SKIP_CHANGELOG": "1",
|
||||
"CURSOR_COMPOSER_MODE": mode,
|
||||
}
|
||||
}, ensure_ascii=False, separators=(",", ":")))
|
||||
else:
|
||||
print("{}")
|
||||
' 2>/dev/null
|
||||
) || result='{}'
|
||||
if [ -n "$result" ]; then
|
||||
printf '%s\n' "$result"
|
||||
else
|
||||
printf '{}\n'
|
||||
fi
|
||||
else
|
||||
echo "{}"
|
||||
printf '{}\n'
|
||||
fi
|
||||
|
||||
exit 0
|
||||
|
||||
Reference in New Issue
Block a user