git-ai-control 0.2.0 → 0.4.0
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.
- package/CHANGELOG.md +36 -0
- package/COMPATIBILITY.md +10 -0
- package/README.md +32 -11
- package/bin/git-ai-control.js +20 -22
- package/package.json +10 -7
- package/plugins/upload-filter/plugin_filter_runtime.py +3 -2
- package/scripts/check.mjs +56 -0
- package/scripts/install.mjs +451 -0
- package/scripts/install.sh +1 -237
- package/scripts/platform-services.mjs +145 -0
- package/scripts/uninstall.mjs +123 -0
- package/scripts/uninstall.sh +2 -88
- package/scripts/verify-release.mjs +19 -0
- package/server.py +123 -29
- package/launchagents/com.git-ai.control-panel.plist.in +0 -24
- package/launchagents/com.git-ai.skill-usage-filter.plist.in +0 -22
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import path from "node:path"
|
|
2
|
+
|
|
3
|
+
export const SERVICE_DEFINITIONS = [
|
|
4
|
+
{
|
|
5
|
+
key: "filter",
|
|
6
|
+
macLabel: "com.git-ai.skill-usage-filter",
|
|
7
|
+
linuxUnit: "git-ai-filter.service",
|
|
8
|
+
windowsTask: "GitAIFilter",
|
|
9
|
+
script: ["filters", "plugin_filter_runtime.py"],
|
|
10
|
+
stdout: ["filters", "skill_usage_filter.out.log"],
|
|
11
|
+
stderr: ["filters", "skill_usage_filter.err.log"],
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
key: "control",
|
|
15
|
+
macLabel: "com.git-ai.control-panel",
|
|
16
|
+
linuxUnit: "git-ai-control-panel.service",
|
|
17
|
+
windowsTask: "GitAIControlPanel",
|
|
18
|
+
script: ["control-panel", "server.py"],
|
|
19
|
+
stdout: ["control-panel.out.log"],
|
|
20
|
+
stderr: ["control-panel.err.log"],
|
|
21
|
+
},
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
function xmlEscape(value) {
|
|
25
|
+
return String(value)
|
|
26
|
+
.replaceAll("&", "&")
|
|
27
|
+
.replaceAll("<", "<")
|
|
28
|
+
.replaceAll(">", ">")
|
|
29
|
+
.replaceAll('"', """)
|
|
30
|
+
.replaceAll("'", "'")
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function systemdQuote(value) {
|
|
34
|
+
return `"${String(value).replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function powershellQuote(value) {
|
|
38
|
+
return `'${String(value).replaceAll("'", "''")}'`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function commandArguments(service, gitAiRoot, pythonCommand, pathApi = path) {
|
|
42
|
+
return [
|
|
43
|
+
...pythonCommand,
|
|
44
|
+
pathApi.join(gitAiRoot, ...service.script),
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function renderLaunchAgent(service, gitAiRoot, pythonCommand) {
|
|
49
|
+
const argumentsXml = commandArguments(service, gitAiRoot, pythonCommand, path.posix)
|
|
50
|
+
.map((argument) => ` <string>${xmlEscape(argument)}</string>`)
|
|
51
|
+
.join("\n")
|
|
52
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
53
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
|
54
|
+
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
55
|
+
<plist version="1.0">
|
|
56
|
+
<dict>
|
|
57
|
+
<key>Label</key>
|
|
58
|
+
<string>${xmlEscape(service.macLabel)}</string>
|
|
59
|
+
<key>ProgramArguments</key>
|
|
60
|
+
<array>
|
|
61
|
+
${argumentsXml}
|
|
62
|
+
</array>
|
|
63
|
+
<key>EnvironmentVariables</key>
|
|
64
|
+
<dict>
|
|
65
|
+
<key>GIT_AI_ROOT</key>
|
|
66
|
+
<string>${xmlEscape(gitAiRoot)}</string>
|
|
67
|
+
</dict>
|
|
68
|
+
<key>RunAtLoad</key>
|
|
69
|
+
<true/>
|
|
70
|
+
<key>KeepAlive</key>
|
|
71
|
+
<true/>
|
|
72
|
+
<key>ProcessType</key>
|
|
73
|
+
<string>Background</string>
|
|
74
|
+
<key>StandardOutPath</key>
|
|
75
|
+
<string>${xmlEscape(path.posix.join(gitAiRoot, ...service.stdout))}</string>
|
|
76
|
+
<key>StandardErrorPath</key>
|
|
77
|
+
<string>${xmlEscape(path.posix.join(gitAiRoot, ...service.stderr))}</string>
|
|
78
|
+
</dict>
|
|
79
|
+
</plist>
|
|
80
|
+
`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function renderSystemdUnit(service, gitAiRoot, pythonCommand) {
|
|
84
|
+
const command = commandArguments(service, gitAiRoot, pythonCommand, path.posix)
|
|
85
|
+
.map(systemdQuote)
|
|
86
|
+
.join(" ")
|
|
87
|
+
return `[Unit]
|
|
88
|
+
Description=Git AI ${service.key === "filter" ? "upload filter" : "control panel"}
|
|
89
|
+
|
|
90
|
+
[Service]
|
|
91
|
+
Type=simple
|
|
92
|
+
Environment=${systemdQuote(`GIT_AI_ROOT=${gitAiRoot}`)}
|
|
93
|
+
ExecStart=${command}
|
|
94
|
+
Restart=always
|
|
95
|
+
RestartSec=2
|
|
96
|
+
|
|
97
|
+
[Install]
|
|
98
|
+
WantedBy=default.target
|
|
99
|
+
`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function renderWindowsWrapper(service, gitAiRoot, pythonCommand) {
|
|
103
|
+
const [executable, ...argumentsList] = commandArguments(
|
|
104
|
+
service,
|
|
105
|
+
gitAiRoot,
|
|
106
|
+
pythonCommand,
|
|
107
|
+
path.win32,
|
|
108
|
+
)
|
|
109
|
+
const argumentsExpression = argumentsList.map(powershellQuote).join(" ")
|
|
110
|
+
const stdoutPath = path.win32.join(gitAiRoot, ...service.stdout)
|
|
111
|
+
const stderrPath = path.win32.join(gitAiRoot, ...service.stderr)
|
|
112
|
+
return `$env:GIT_AI_ROOT = ${powershellQuote(gitAiRoot)}
|
|
113
|
+
& ${powershellQuote(executable)} ${argumentsExpression} 1>> ${powershellQuote(stdoutPath)} 2>> ${powershellQuote(stderrPath)}
|
|
114
|
+
exit $LASTEXITCODE
|
|
115
|
+
`
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function browserCommand(platform, url) {
|
|
119
|
+
if (platform === "darwin") {
|
|
120
|
+
return {command: "/usr/bin/open", args: [url]}
|
|
121
|
+
}
|
|
122
|
+
if (platform === "linux") {
|
|
123
|
+
return {command: "xdg-open", args: [url]}
|
|
124
|
+
}
|
|
125
|
+
if (platform === "win32") {
|
|
126
|
+
return {
|
|
127
|
+
command: "cmd.exe",
|
|
128
|
+
args: ["/d", "/s", "/c", "start", '""', url],
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return null
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function gitAiBinaryCandidates(gitAiRoot, platform) {
|
|
135
|
+
const pathApi = platform === "win32" ? path.win32 : path.posix
|
|
136
|
+
const candidates = [pathApi.join(gitAiRoot, "bin", "git-ai")]
|
|
137
|
+
if (platform === "win32") {
|
|
138
|
+
candidates.unshift(pathApi.join(gitAiRoot, "bin", "git-ai.exe"))
|
|
139
|
+
}
|
|
140
|
+
return candidates
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function windowsTaskCommand(wrapperPath) {
|
|
144
|
+
return `powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "${wrapperPath.replaceAll('"', '""')}"`
|
|
145
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import {spawnSync} from "node:child_process"
|
|
2
|
+
import fs from "node:fs"
|
|
3
|
+
import os from "node:os"
|
|
4
|
+
import path from "node:path"
|
|
5
|
+
import {fileURLToPath} from "node:url"
|
|
6
|
+
|
|
7
|
+
import {SERVICE_DEFINITIONS} from "./platform-services.mjs"
|
|
8
|
+
|
|
9
|
+
const ROUTE_KEYS = {
|
|
10
|
+
"/legacy/commit": "commit_endpoint",
|
|
11
|
+
"/legacy/checkpoint": "checkpoint_endpoint",
|
|
12
|
+
"/legacy/token-usage": "token_usage_endpoint",
|
|
13
|
+
"/prompt-duration": "prompt_duration_endpoint",
|
|
14
|
+
"/commit": "commit_endpoint_v2",
|
|
15
|
+
"/checkpoint": "checkpoint_endpoint_v2",
|
|
16
|
+
"/token-usage": "token_usage_endpoint_v2",
|
|
17
|
+
"/skill-usage": "skill_usage_endpoint_v2",
|
|
18
|
+
"/agent-usage": "agent_usage_endpoint_v2",
|
|
19
|
+
"/prompt-report": "prompt_report_endpoint_v2",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function run(command, args) {
|
|
23
|
+
return spawnSync(command, args, {
|
|
24
|
+
encoding: "utf8",
|
|
25
|
+
stdio: "pipe",
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function readJson(filePath) {
|
|
30
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function writeJsonAtomic(filePath, value) {
|
|
34
|
+
const temporaryPath = path.join(
|
|
35
|
+
path.dirname(filePath),
|
|
36
|
+
`.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`,
|
|
37
|
+
)
|
|
38
|
+
fs.writeFileSync(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, {mode: 0o600})
|
|
39
|
+
fs.renameSync(temporaryPath, filePath)
|
|
40
|
+
if (process.platform !== "win32") {
|
|
41
|
+
fs.chmodSync(filePath, 0o600)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function removeServices(platform) {
|
|
46
|
+
if (platform === "darwin") {
|
|
47
|
+
const domain = `gui/${process.getuid()}`
|
|
48
|
+
for (const service of SERVICE_DEFINITIONS) {
|
|
49
|
+
run("launchctl", ["bootout", `${domain}/${service.macLabel}`])
|
|
50
|
+
fs.rmSync(
|
|
51
|
+
path.join(os.homedir(), "Library", "LaunchAgents", `${service.macLabel}.plist`),
|
|
52
|
+
{force: true},
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
} else if (platform === "linux") {
|
|
56
|
+
for (const service of SERVICE_DEFINITIONS) {
|
|
57
|
+
run("systemctl", ["--user", "disable", "--now", service.linuxUnit])
|
|
58
|
+
fs.rmSync(
|
|
59
|
+
path.join(os.homedir(), ".config", "systemd", "user", service.linuxUnit),
|
|
60
|
+
{force: true},
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
run("systemctl", ["--user", "daemon-reload"])
|
|
64
|
+
} else if (platform === "win32") {
|
|
65
|
+
for (const service of SERVICE_DEFINITIONS) {
|
|
66
|
+
run("schtasks.exe", ["/End", "/TN", service.windowsTask])
|
|
67
|
+
run("schtasks.exe", ["/Delete", "/F", "/TN", service.windowsTask])
|
|
68
|
+
}
|
|
69
|
+
} else {
|
|
70
|
+
throw new Error(`git-ai-control 不支持当前操作系统:${platform}`)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function uninstall(options = {}) {
|
|
75
|
+
const platform = options.platform ?? process.platform
|
|
76
|
+
const defaultRoot = path.resolve(path.join(os.homedir(), ".git-ai"))
|
|
77
|
+
const gitAiRoot = path.resolve(
|
|
78
|
+
options.gitAiRoot ?? process.env.GIT_AI_ROOT ?? defaultRoot,
|
|
79
|
+
)
|
|
80
|
+
if (gitAiRoot !== defaultRoot && !options.allowCustomRoot) {
|
|
81
|
+
throw new Error(`拒绝卸载非默认目录:${gitAiRoot}`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
removeServices(platform)
|
|
85
|
+
|
|
86
|
+
const controlPanelDir = path.join(gitAiRoot, "control-panel")
|
|
87
|
+
const markerPath = path.join(controlPanelDir, ".custom-metrics-managed")
|
|
88
|
+
const metricsPath = path.join(gitAiRoot, "custom_metrics.json")
|
|
89
|
+
const upstreamsPath = path.join(gitAiRoot, "upstream_metrics.json")
|
|
90
|
+
if (fs.existsSync(markerPath) && fs.existsSync(metricsPath)) {
|
|
91
|
+
if (!fs.existsSync(upstreamsPath)) {
|
|
92
|
+
throw new Error("缺少原上报端点备份,已停止卸载以避免留下无效配置")
|
|
93
|
+
}
|
|
94
|
+
const config = readJson(metricsPath)
|
|
95
|
+
const saved = readJson(upstreamsPath)
|
|
96
|
+
const routes = saved.routes ?? saved
|
|
97
|
+
for (const [route, key] of Object.entries(ROUTE_KEYS)) {
|
|
98
|
+
if (typeof routes[route] === "string" && routes[route]) {
|
|
99
|
+
config[key] = routes[route]
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
writeJsonAtomic(metricsPath, config)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
fs.rmSync(path.join(gitAiRoot, "filters", "plugin_filter_runtime.py"), {
|
|
106
|
+
force: true,
|
|
107
|
+
})
|
|
108
|
+
fs.rmSync(controlPanelDir, {recursive: true, force: true})
|
|
109
|
+
fs.rmSync(path.join(gitAiRoot, "services"), {recursive: true, force: true})
|
|
110
|
+
|
|
111
|
+
console.log("卸载完成。保留了以下用户数据:")
|
|
112
|
+
console.log(` ${path.join(gitAiRoot, "config.json")}`)
|
|
113
|
+
console.log(` ${path.join(gitAiRoot, "filter_plugins.json")}`)
|
|
114
|
+
console.log(` ${path.join(gitAiRoot, "upstream_metrics.json")}`)
|
|
115
|
+
console.log(` ${path.join(gitAiRoot, "filters")} 下的日志`)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
119
|
+
uninstall().catch((error) => {
|
|
120
|
+
console.error(`卸载失败:${error.message}`)
|
|
121
|
+
process.exitCode = 1
|
|
122
|
+
})
|
|
123
|
+
}
|
package/scripts/uninstall.sh
CHANGED
|
@@ -1,91 +1,5 @@
|
|
|
1
1
|
#!/bin/sh
|
|
2
2
|
set -eu
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
CONTROL_PANEL_DIR="${GIT_AI_ROOT}/control-panel"
|
|
7
|
-
FILTER_SCRIPT="${GIT_AI_ROOT}/filters/plugin_filter_runtime.py"
|
|
8
|
-
METRICS_PATH="${GIT_AI_ROOT}/custom_metrics.json"
|
|
9
|
-
UPSTREAMS_PATH="${GIT_AI_ROOT}/upstream_metrics.json"
|
|
10
|
-
CUSTOM_METRICS_MARKER="${CONTROL_PANEL_DIR}/.custom-metrics-managed"
|
|
11
|
-
CONTROL_PANEL_PLIST="${LAUNCH_AGENTS_ROOT}/com.git-ai.control-panel.plist"
|
|
12
|
-
FILTER_PLIST="${LAUNCH_AGENTS_ROOT}/com.git-ai.skill-usage-filter.plist"
|
|
13
|
-
CONTROL_PANEL_LABEL="gui/$(id -u)/com.git-ai.control-panel"
|
|
14
|
-
FILTER_LABEL="gui/$(id -u)/com.git-ai.skill-usage-filter"
|
|
15
|
-
|
|
16
|
-
case "${GIT_AI_ROOT}" in
|
|
17
|
-
"${HOME}/.git-ai") ;;
|
|
18
|
-
*)
|
|
19
|
-
echo "拒绝卸载非默认目录:${GIT_AI_ROOT}" >&2
|
|
20
|
-
exit 1
|
|
21
|
-
;;
|
|
22
|
-
esac
|
|
23
|
-
|
|
24
|
-
launchctl bootout "${CONTROL_PANEL_LABEL}" >/dev/null 2>&1 || true
|
|
25
|
-
launchctl bootout "${FILTER_LABEL}" >/dev/null 2>&1 || true
|
|
26
|
-
|
|
27
|
-
if [ -f "${CUSTOM_METRICS_MARKER}" ]; then
|
|
28
|
-
/usr/bin/python3 - "${METRICS_PATH}" "${UPSTREAMS_PATH}" <<'PY'
|
|
29
|
-
import json
|
|
30
|
-
import os
|
|
31
|
-
import sys
|
|
32
|
-
import tempfile
|
|
33
|
-
from pathlib import Path
|
|
34
|
-
|
|
35
|
-
path = Path(sys.argv[1])
|
|
36
|
-
upstreams_path = Path(sys.argv[2])
|
|
37
|
-
if not path.exists():
|
|
38
|
-
raise SystemExit(0)
|
|
39
|
-
if not upstreams_path.exists():
|
|
40
|
-
raise SystemExit("缺少原上报端点备份,已停止卸载以避免留下无效配置")
|
|
41
|
-
|
|
42
|
-
config = json.loads(path.read_text(encoding="utf-8"))
|
|
43
|
-
upstreams = json.loads(upstreams_path.read_text(encoding="utf-8"))
|
|
44
|
-
routes = upstreams.get("routes", upstreams)
|
|
45
|
-
route_keys = {
|
|
46
|
-
"/legacy/commit": "commit_endpoint",
|
|
47
|
-
"/legacy/checkpoint": "checkpoint_endpoint",
|
|
48
|
-
"/legacy/token-usage": "token_usage_endpoint",
|
|
49
|
-
"/prompt-duration": "prompt_duration_endpoint",
|
|
50
|
-
"/commit": "commit_endpoint_v2",
|
|
51
|
-
"/checkpoint": "checkpoint_endpoint_v2",
|
|
52
|
-
"/token-usage": "token_usage_endpoint_v2",
|
|
53
|
-
"/skill-usage": "skill_usage_endpoint_v2",
|
|
54
|
-
"/agent-usage": "agent_usage_endpoint_v2",
|
|
55
|
-
"/prompt-report": "prompt_report_endpoint_v2",
|
|
56
|
-
}
|
|
57
|
-
for route, key in route_keys.items():
|
|
58
|
-
url = routes.get(route)
|
|
59
|
-
if isinstance(url, str) and url:
|
|
60
|
-
config[key] = url
|
|
61
|
-
|
|
62
|
-
descriptor, temporary_name = tempfile.mkstemp(
|
|
63
|
-
prefix=f".{path.name}.",
|
|
64
|
-
suffix=".tmp",
|
|
65
|
-
dir=path.parent,
|
|
66
|
-
)
|
|
67
|
-
try:
|
|
68
|
-
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
69
|
-
json.dump(config, handle, ensure_ascii=False, indent=2)
|
|
70
|
-
handle.write("\n")
|
|
71
|
-
handle.flush()
|
|
72
|
-
os.fsync(handle.fileno())
|
|
73
|
-
os.chmod(temporary_name, 0o600)
|
|
74
|
-
os.replace(temporary_name, path)
|
|
75
|
-
except Exception:
|
|
76
|
-
try:
|
|
77
|
-
os.unlink(temporary_name)
|
|
78
|
-
except FileNotFoundError:
|
|
79
|
-
pass
|
|
80
|
-
raise
|
|
81
|
-
PY
|
|
82
|
-
fi
|
|
83
|
-
|
|
84
|
-
rm -f "${CONTROL_PANEL_PLIST}" "${FILTER_PLIST}" "${FILTER_SCRIPT}"
|
|
85
|
-
rm -rf "${CONTROL_PANEL_DIR}"
|
|
86
|
-
|
|
87
|
-
echo "卸载完成。保留了以下用户数据:"
|
|
88
|
-
echo " ${GIT_AI_ROOT}/config.json"
|
|
89
|
-
echo " ${GIT_AI_ROOT}/filter_plugins.json"
|
|
90
|
-
echo " ${GIT_AI_ROOT}/upstream_metrics.json"
|
|
91
|
-
echo " ${GIT_AI_ROOT}/filters/*.log"
|
|
4
|
+
PROJECT_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
|
5
|
+
exec node "${PROJECT_ROOT}/scripts/uninstall.mjs"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import fs from "node:fs"
|
|
2
|
+
|
|
3
|
+
const tag = process.argv[2]
|
|
4
|
+
const packageJson = JSON.parse(
|
|
5
|
+
fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"),
|
|
6
|
+
)
|
|
7
|
+
const expectedTag = `v${packageJson.version}`
|
|
8
|
+
|
|
9
|
+
if (!tag) {
|
|
10
|
+
console.error("缺少版本标签")
|
|
11
|
+
process.exit(1)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (tag !== expectedTag) {
|
|
15
|
+
console.error(`版本标签 ${tag} 与 package.json ${packageJson.version} 不一致`)
|
|
16
|
+
process.exit(1)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
console.log(`发布版本校验通过:${packageJson.name}@${packageJson.version}`)
|
package/server.py
CHANGED
|
@@ -5,8 +5,10 @@ from __future__ import annotations
|
|
|
5
5
|
|
|
6
6
|
import json
|
|
7
7
|
import os
|
|
8
|
+
import platform
|
|
8
9
|
import py_compile
|
|
9
10
|
import re
|
|
11
|
+
import shutil
|
|
10
12
|
import subprocess
|
|
11
13
|
import tempfile
|
|
12
14
|
import urllib.error
|
|
@@ -21,13 +23,15 @@ HOST = "127.0.0.1"
|
|
|
21
23
|
PORT = 38742
|
|
22
24
|
APP_ROOT = Path(__file__).resolve().parent
|
|
23
25
|
STATIC_ROOT = APP_ROOT / "static"
|
|
24
|
-
GIT_AI_ROOT = Path.home() / ".git-ai"
|
|
26
|
+
GIT_AI_ROOT = Path(os.environ.get("GIT_AI_ROOT", Path.home() / ".git-ai")).resolve()
|
|
25
27
|
NATIVE_CONFIG_PATH = GIT_AI_ROOT / "config.json"
|
|
26
28
|
POLICY_CONFIG_PATH = GIT_AI_ROOT / "filter_plugins.json"
|
|
27
29
|
CUSTOM_METRICS_PATH = GIT_AI_ROOT / "custom_metrics.json"
|
|
28
30
|
FILTER_SCRIPT_PATH = GIT_AI_ROOT / "filters" / "plugin_filter_runtime.py"
|
|
29
31
|
FILTER_HEALTH_URL = "http://127.0.0.1:38741/health"
|
|
30
|
-
|
|
32
|
+
FILTER_MAC_LABEL = "com.git-ai.skill-usage-filter"
|
|
33
|
+
FILTER_LINUX_UNIT = "git-ai-filter.service"
|
|
34
|
+
FILTER_WINDOWS_TASK = "GitAIFilter"
|
|
31
35
|
|
|
32
36
|
NATIVE_FIELDS = {
|
|
33
37
|
"git_path",
|
|
@@ -88,7 +92,10 @@ def atomic_write_json(path: Path, value) -> None:
|
|
|
88
92
|
|
|
89
93
|
def public_native_config() -> dict:
|
|
90
94
|
raw = read_json(NATIVE_CONFIG_PATH, {})
|
|
91
|
-
|
|
95
|
+
public = {key: raw.get(key) for key in NATIVE_FIELDS if key in raw}
|
|
96
|
+
if not public.get("git_path"):
|
|
97
|
+
public["git_path"] = shutil.which("git") or ""
|
|
98
|
+
return public
|
|
92
99
|
|
|
93
100
|
|
|
94
101
|
def validate_string_list(value, label: str, *, maximum: int = 128) -> list[str]:
|
|
@@ -109,7 +116,11 @@ def validate_native_config(value) -> dict:
|
|
|
109
116
|
result = {}
|
|
110
117
|
if "git_path" in value:
|
|
111
118
|
git_path = value["git_path"]
|
|
112
|
-
if
|
|
119
|
+
if (
|
|
120
|
+
not isinstance(git_path, str)
|
|
121
|
+
or not os.path.isabs(git_path)
|
|
122
|
+
or len(git_path) > 500
|
|
123
|
+
):
|
|
113
124
|
raise ConfigError("Git 路径必须是绝对路径")
|
|
114
125
|
result["git_path"] = git_path
|
|
115
126
|
|
|
@@ -185,7 +196,7 @@ def validate_policy_config(value) -> dict:
|
|
|
185
196
|
fixed_directory = plugin.get("fixed_project_directory", "")
|
|
186
197
|
if not isinstance(fixed_directory, str) or len(fixed_directory) > 500:
|
|
187
198
|
raise ConfigError(f"插件 {plugin_id} 的固定项目目录无效")
|
|
188
|
-
if fixed_directory and not
|
|
199
|
+
if fixed_directory and not os.path.isabs(fixed_directory):
|
|
189
200
|
raise ConfigError(f"插件 {plugin_id} 的固定项目目录必须是绝对路径")
|
|
190
201
|
|
|
191
202
|
allow = plugin.get("allow")
|
|
@@ -261,23 +272,78 @@ def filter_health() -> dict:
|
|
|
261
272
|
return {"ok": False, "error": str(error)}
|
|
262
273
|
|
|
263
274
|
|
|
264
|
-
def
|
|
265
|
-
result = subprocess.run(
|
|
266
|
-
["launchctl", "print", FILTER_LAUNCH_AGENT],
|
|
267
|
-
check=False,
|
|
268
|
-
capture_output=True,
|
|
269
|
-
text=True,
|
|
270
|
-
timeout=3,
|
|
271
|
-
)
|
|
272
|
-
state_match = re.search(r"^\s*state = (\w+)", result.stdout, re.MULTILINE)
|
|
275
|
+
def service_manager_name() -> str:
|
|
273
276
|
return {
|
|
274
|
-
"
|
|
275
|
-
"
|
|
276
|
-
|
|
277
|
+
"Darwin": "launchd",
|
|
278
|
+
"Linux": "systemd-user",
|
|
279
|
+
"Windows": "task-scheduler",
|
|
280
|
+
}.get(platform.system(), "unknown")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def filter_service_status(health: dict | None = None) -> dict:
|
|
284
|
+
manager = service_manager_name()
|
|
285
|
+
if health and health.get("ok"):
|
|
286
|
+
return {"ok": True, "state": "running", "manager": manager}
|
|
287
|
+
|
|
288
|
+
try:
|
|
289
|
+
if platform.system() == "Darwin":
|
|
290
|
+
label = f"gui/{os.getuid()}/{FILTER_MAC_LABEL}"
|
|
291
|
+
result = subprocess.run(
|
|
292
|
+
["launchctl", "print", label],
|
|
293
|
+
check=False,
|
|
294
|
+
capture_output=True,
|
|
295
|
+
text=True,
|
|
296
|
+
timeout=3,
|
|
297
|
+
)
|
|
298
|
+
state_match = re.search(r"^\s*state = (\w+)", result.stdout, re.MULTILINE)
|
|
299
|
+
state = state_match.group(1) if state_match else "missing"
|
|
300
|
+
return {
|
|
301
|
+
"ok": result.returncode == 0 and state == "running",
|
|
302
|
+
"state": state,
|
|
303
|
+
"manager": manager,
|
|
304
|
+
}
|
|
305
|
+
if platform.system() == "Linux":
|
|
306
|
+
result = subprocess.run(
|
|
307
|
+
["systemctl", "--user", "is-active", FILTER_LINUX_UNIT],
|
|
308
|
+
check=False,
|
|
309
|
+
capture_output=True,
|
|
310
|
+
text=True,
|
|
311
|
+
timeout=3,
|
|
312
|
+
)
|
|
313
|
+
state = result.stdout.strip() or "missing"
|
|
314
|
+
return {
|
|
315
|
+
"ok": result.returncode == 0 and state == "active",
|
|
316
|
+
"state": "running" if state == "active" else state,
|
|
317
|
+
"manager": manager,
|
|
318
|
+
}
|
|
319
|
+
if platform.system() == "Windows":
|
|
320
|
+
result = subprocess.run(
|
|
321
|
+
["schtasks.exe", "/Query", "/TN", FILTER_WINDOWS_TASK],
|
|
322
|
+
check=False,
|
|
323
|
+
capture_output=True,
|
|
324
|
+
text=True,
|
|
325
|
+
timeout=3,
|
|
326
|
+
)
|
|
327
|
+
return {
|
|
328
|
+
"ok": False,
|
|
329
|
+
"state": "registered" if result.returncode == 0 else "missing",
|
|
330
|
+
"manager": manager,
|
|
331
|
+
}
|
|
332
|
+
except Exception as error:
|
|
333
|
+
return {"ok": False, "state": "unknown", "manager": manager, "error": str(error)}
|
|
334
|
+
return {"ok": False, "state": "unsupported", "manager": manager}
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def git_ai_binary_path() -> Path:
|
|
338
|
+
candidates = [
|
|
339
|
+
GIT_AI_ROOT / "bin" / "git-ai.exe",
|
|
340
|
+
GIT_AI_ROOT / "bin" / "git-ai",
|
|
341
|
+
]
|
|
342
|
+
return next((candidate for candidate in candidates if candidate.exists()), candidates[-1])
|
|
277
343
|
|
|
278
344
|
|
|
279
345
|
def git_ai_version() -> str:
|
|
280
|
-
binary =
|
|
346
|
+
binary = git_ai_binary_path()
|
|
281
347
|
try:
|
|
282
348
|
result = subprocess.run(
|
|
283
349
|
[str(binary), "--version"],
|
|
@@ -293,7 +359,7 @@ def git_ai_version() -> str:
|
|
|
293
359
|
|
|
294
360
|
@lru_cache(maxsize=1)
|
|
295
361
|
def custom_metrics_supported() -> bool:
|
|
296
|
-
binary =
|
|
362
|
+
binary = git_ai_binary_path()
|
|
297
363
|
try:
|
|
298
364
|
return b"custom_metrics.json" in binary.read_bytes()
|
|
299
365
|
except Exception:
|
|
@@ -323,14 +389,14 @@ def custom_metrics_routed() -> bool:
|
|
|
323
389
|
|
|
324
390
|
def runtime_status() -> dict:
|
|
325
391
|
health = filter_health()
|
|
326
|
-
|
|
392
|
+
service = filter_service_status(health)
|
|
327
393
|
supports_custom_metrics = custom_metrics_supported()
|
|
328
394
|
routes_are_local = custom_metrics_routed() if supports_custom_metrics else False
|
|
329
395
|
granular_filter_active = (
|
|
330
396
|
supports_custom_metrics
|
|
331
397
|
and routes_are_local
|
|
332
398
|
and bool(health.get("ok"))
|
|
333
|
-
and bool(
|
|
399
|
+
and bool(service.get("ok"))
|
|
334
400
|
)
|
|
335
401
|
return {
|
|
336
402
|
"ok": granular_filter_active if supports_custom_metrics else True,
|
|
@@ -341,7 +407,9 @@ def runtime_status() -> dict:
|
|
|
341
407
|
"granularUploadFilter": supports_custom_metrics,
|
|
342
408
|
},
|
|
343
409
|
"filter": health,
|
|
344
|
-
"launchAgent":
|
|
410
|
+
"launchAgent": service,
|
|
411
|
+
"service": service,
|
|
412
|
+
"platform": platform.system().lower(),
|
|
345
413
|
"gitAiVersion": git_ai_version(),
|
|
346
414
|
"paths": {
|
|
347
415
|
"nativeConfig": str(NATIVE_CONFIG_PATH),
|
|
@@ -394,6 +462,38 @@ def run_self_test() -> dict:
|
|
|
394
462
|
return {"ok": all(item["ok"] for item in checks), "checks": checks}
|
|
395
463
|
|
|
396
464
|
|
|
465
|
+
def restart_filter_service() -> subprocess.CompletedProcess:
|
|
466
|
+
system = platform.system()
|
|
467
|
+
if system == "Darwin":
|
|
468
|
+
label = f"gui/{os.getuid()}/{FILTER_MAC_LABEL}"
|
|
469
|
+
command = ["launchctl", "kickstart", "-k", label]
|
|
470
|
+
elif system == "Linux":
|
|
471
|
+
command = ["systemctl", "--user", "restart", FILTER_LINUX_UNIT]
|
|
472
|
+
elif system == "Windows":
|
|
473
|
+
subprocess.run(
|
|
474
|
+
["schtasks.exe", "/End", "/TN", FILTER_WINDOWS_TASK],
|
|
475
|
+
check=False,
|
|
476
|
+
capture_output=True,
|
|
477
|
+
text=True,
|
|
478
|
+
timeout=6,
|
|
479
|
+
)
|
|
480
|
+
command = ["schtasks.exe", "/Run", "/TN", FILTER_WINDOWS_TASK]
|
|
481
|
+
else:
|
|
482
|
+
return subprocess.CompletedProcess(
|
|
483
|
+
args=[],
|
|
484
|
+
returncode=1,
|
|
485
|
+
stdout="",
|
|
486
|
+
stderr=f"不支持当前操作系统:{system}",
|
|
487
|
+
)
|
|
488
|
+
return subprocess.run(
|
|
489
|
+
command,
|
|
490
|
+
check=False,
|
|
491
|
+
capture_output=True,
|
|
492
|
+
text=True,
|
|
493
|
+
timeout=6,
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
|
|
397
497
|
class Handler(BaseHTTPRequestHandler):
|
|
398
498
|
server_version = "git-ai-control-panel/1.0"
|
|
399
499
|
|
|
@@ -459,13 +559,7 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
459
559
|
self.send_json(200, run_self_test())
|
|
460
560
|
return
|
|
461
561
|
if path == "/api/restart-filter":
|
|
462
|
-
result =
|
|
463
|
-
["launchctl", "kickstart", "-k", FILTER_LAUNCH_AGENT],
|
|
464
|
-
check=False,
|
|
465
|
-
capture_output=True,
|
|
466
|
-
text=True,
|
|
467
|
-
timeout=6,
|
|
468
|
-
)
|
|
562
|
+
result = restart_filter_service()
|
|
469
563
|
self.send_json(
|
|
470
564
|
200 if result.returncode == 0 else 500,
|
|
471
565
|
{
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
|
3
|
-
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
4
|
-
<plist version="1.0">
|
|
5
|
-
<dict>
|
|
6
|
-
<key>Label</key>
|
|
7
|
-
<string>com.git-ai.control-panel</string>
|
|
8
|
-
<key>ProgramArguments</key>
|
|
9
|
-
<array>
|
|
10
|
-
<string>/usr/bin/python3</string>
|
|
11
|
-
<string>__GIT_AI_ROOT__/control-panel/server.py</string>
|
|
12
|
-
</array>
|
|
13
|
-
<key>RunAtLoad</key>
|
|
14
|
-
<true/>
|
|
15
|
-
<key>KeepAlive</key>
|
|
16
|
-
<true/>
|
|
17
|
-
<key>ProcessType</key>
|
|
18
|
-
<string>Background</string>
|
|
19
|
-
<key>StandardOutPath</key>
|
|
20
|
-
<string>__GIT_AI_ROOT__/control-panel.out.log</string>
|
|
21
|
-
<key>StandardErrorPath</key>
|
|
22
|
-
<string>__GIT_AI_ROOT__/control-panel.err.log</string>
|
|
23
|
-
</dict>
|
|
24
|
-
</plist>
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
|
3
|
-
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
4
|
-
<plist version="1.0">
|
|
5
|
-
<dict>
|
|
6
|
-
<key>Label</key>
|
|
7
|
-
<string>com.git-ai.skill-usage-filter</string>
|
|
8
|
-
<key>ProgramArguments</key>
|
|
9
|
-
<array>
|
|
10
|
-
<string>/usr/bin/python3</string>
|
|
11
|
-
<string>__GIT_AI_ROOT__/filters/plugin_filter_runtime.py</string>
|
|
12
|
-
</array>
|
|
13
|
-
<key>RunAtLoad</key>
|
|
14
|
-
<true/>
|
|
15
|
-
<key>KeepAlive</key>
|
|
16
|
-
<true/>
|
|
17
|
-
<key>StandardOutPath</key>
|
|
18
|
-
<string>__GIT_AI_ROOT__/filters/skill_usage_filter.out.log</string>
|
|
19
|
-
<key>StandardErrorPath</key>
|
|
20
|
-
<string>__GIT_AI_ROOT__/filters/skill_usage_filter.err.log</string>
|
|
21
|
-
</dict>
|
|
22
|
-
</plist>
|