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.
@@ -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("<", "&lt;")
28
+ .replaceAll(">", "&gt;")
29
+ .replaceAll('"', "&quot;")
30
+ .replaceAll("'", "&apos;")
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
+ }
@@ -1,91 +1,5 @@
1
1
  #!/bin/sh
2
2
  set -eu
3
3
 
4
- GIT_AI_ROOT=${GIT_AI_ROOT:-"${HOME}/.git-ai"}
5
- LAUNCH_AGENTS_ROOT="${HOME}/Library/LaunchAgents"
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
- FILTER_LAUNCH_AGENT = f"gui/{os.getuid()}/com.git-ai.skill-usage-filter"
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
- return {key: raw.get(key) for key in NATIVE_FIELDS if key in raw}
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 not isinstance(git_path, str) or not git_path.startswith("/") or len(git_path) > 500:
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 fixed_directory.startswith("/"):
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 launch_agent_status() -> dict:
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
- "ok": result.returncode == 0 and state_match and state_match.group(1) == "running",
275
- "state": state_match.group(1) if state_match else "missing",
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 = GIT_AI_ROOT / "bin" / "git-ai"
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 = GIT_AI_ROOT / "bin" / "git-ai"
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
- agent = launch_agent_status()
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(agent.get("ok"))
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": agent,
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 = subprocess.run(
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>