Add original git-tools plugin

This commit is contained in:
CJYSEX
2026-07-22 23:38:14 +08:00
parent ad9403f7fc
commit 9a24a2125f
9 changed files with 405 additions and 0 deletions

View File

@@ -0,0 +1,38 @@
{
"name": "git-tools",
"version": "0.1.0",
"description": "面向Codex用户的原创Git、Gitea和GitHub安全管理工具。",
"author": {
"name": "CJYSEX",
"url": "https://git.cjysa.top/CJYSA"
},
"homepage": "https://git.cjysa.top/CJYSA/codex-free-market",
"repository": "https://git.cjysa.top/CJYSA/codex-free-market",
"license": "MIT",
"keywords": [
"git",
"gitea",
"github",
"repository",
"diagnostics"
],
"skills": "./skills/",
"interface": {
"displayName": "Git Tools",
"shortDescription": "安全管理Git、Gitea、GitHub仓库和双远程镜像。",
"longDescription": "提供仓库状态诊断、安全提交与推送、Gitea/GitHub双远程同步、SSH和HTTPS故障排查并默认保护凭据及用户未提交的修改。",
"developerName": "CJYSEX",
"category": "Developer Tools",
"capabilities": [
"Diagnostics",
"Read",
"Write"
],
"websiteURL": "https://git.cjysa.top/CJYSA/codex-free-market",
"defaultPrompt": [
"检查当前Git仓库状态并给出安全的下一步。",
"帮我把这个项目发布到Gitea并保留现有修改。",
"配置Gitea主仓库和GitHub镜像并安全同步。"
]
}
}

21
plugins/git-tools/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 CJYSEX
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,13 @@
# Git Tools
CJYSA原创的Codex Git仓库管理插件。
主要能力:
- 仓库状态和远程地址诊断
- 安全初始化、提交和推送
- Gitea主仓库与GitHub镜像同步
- SSH、HTTPS及权限故障排查
- 凭据脱敏与本地修改保护
本插件不包含OpenAI官方GitHub插件的代码、Prompt、素材或其他受限内容。

View File

@@ -0,0 +1,119 @@
---
name: manage-git-repositories
description: Safely inspect, initialize, publish, synchronize, and troubleshoot Git repositories across local Git, Gitea, GitHub, GitLab, and generic SSH or HTTPS remotes. Use when the user asks about repository status, commits, branches, remotes, clone or push failures, SSH keys, Git credentials, publishing a project, configuring a primary remote plus a mirror, or recovering from Git conflicts without losing local work.
---
# Manage Git Repositories
Operate conservatively. Preserve local work, verify exact targets, and show evidence before changing repository state.
## Safety Rules
- Never display, commit, log, or transmit passwords, access tokens, private keys, cookies, or credential-bearing URLs.
- Redact URL user information, token query parameters, and authorization headers.
- Treat existing modified, staged, and untracked files as user-owned work.
- Do not run `reset --hard`, forced checkout, clean, history rewriting, force push, recursive deletion, or branch deletion unless the user explicitly requests the exact destructive operation.
- Before a merge, rebase, cherry-pick, remote replacement, or branch rewrite, inspect status and explain the impact.
- Prefer non-interactive Git commands and explicit repository paths.
- Do not create commits, tags, releases, pull requests, or remote repositories unless the user requested publication or a change.
## Start With Diagnosis
Run the bundled read-only diagnostic script:
```bash
python <skill-dir>/scripts/git_diagnose.py <repository-path>
```
Use `--json` for structured output. Use `--check-remotes` only when remote network access is relevant because it can trigger authentication.
Then confirm:
1. Repository root and current branch.
2. Modified, staged, untracked, conflicted, ahead, and behind counts.
3. Upstream branch and every remote URL after redaction.
4. Git author configuration.
5. Whether the intended remote is reachable.
## Initialize Or Publish
For an ordinary new repository:
1. Inspect the directory for secrets, generated files, and existing user work.
2. Create or review `.gitignore`.
3. Initialize Git only when no repository exists.
4. Configure commit identity only with user-approved values.
5. Review the exact staged diff.
6. Commit with a specific message.
7. Add the requested remote.
8. Fetch the remote before the first push when it already contains commits.
9. Merge unrelated initial content carefully instead of overwriting it.
10. Push without force and verify the remote commit hash.
## Configure Gitea And GitHub Mirrors
Prefer clear remote roles:
```text
gitea primary customer-accessible repository
origin GitHub or another public mirror
```
Before synchronizing:
- Verify both remotes point to the intended repositories.
- Fetch both remotes.
- Compare local `HEAD` with each remote branch.
- Push to the primary first, verify it, then push the identical commit to the mirror.
- Report partial success when one remote is unavailable.
Do not silently force two divergent histories to match.
## Troubleshoot Clone And Push
Diagnose in layers:
1. DNS resolution.
2. TCP connectivity to HTTPS 443 or SSH port.
3. TLS certificate and hostname.
4. Git client and proxy configuration.
5. SSH config, selected identity, and host key.
6. Repository existence.
7. User or team permission.
8. Branch protection and non-fast-forward rejection.
Interpret common failures:
- `connection reset`: network path, proxy, CDN, or remote endpoint.
- `permission denied (publickey)`: wrong key, wrong SSH client, missing account key, or `IdentitiesOnly` mismatch.
- `authentication failed`: expired token, wrong credential helper entry, or insufficient scope.
- `repository not found`: wrong owner/path or no read permission.
- `non-fast-forward`: remote contains commits not present locally; fetch and integrate.
- `unrelated histories`: remote and local were initialized independently; preserve both and merge deliberately.
## Commit And Review
Before committing:
- Run `git diff --check`.
- Review `git status --short`.
- Review staged paths and staged diff.
- Scan staged content for likely credentials.
- Avoid mixing unrelated user changes into the commit.
After pushing:
- Compare local and remote branch hashes.
- Report the branch and commit hash.
- State which remotes succeeded.
## Output
Return:
1. Current repository state.
2. Actions taken.
3. Remote and branch results.
4. Remaining risk or user action.
Do not claim a push, mirror, or authentication succeeded without a command result proving it.

View File

@@ -0,0 +1,4 @@
interface:
display_name: "Git Tools"
short_description: "安全管理 Git、Gitea 和 GitHub 仓库"
default_prompt: "检查当前仓库状态并给出安全的下一步。"

View File

@@ -0,0 +1,182 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import pathlib
import re
import subprocess
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
TOKEN_KEYS = {
"access_token",
"api_key",
"apikey",
"auth",
"key",
"password",
"signature",
"token",
}
def git(repo: pathlib.Path, *args: str, timeout: int = 20) -> tuple[int, str, str]:
try:
result = subprocess.run(
["git", "-C", str(repo), *args],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
check=False,
)
except FileNotFoundError:
return 127, "", "git executable not found"
except subprocess.TimeoutExpired:
return 124, "", "git command timed out"
return result.returncode, result.stdout.strip(), result.stderr.strip()
def redact_url(value: str) -> str:
if re.match(r"^[^/@\s]+@[^:\s]+:.+$", value):
user_host, path = value.split(":", 1)
user, host = user_host.split("@", 1)
return f"{user}@{host}:{path}"
try:
parts = urlsplit(value)
except ValueError:
return "[invalid remote URL]"
if not parts.scheme or not parts.netloc:
return value
host = parts.hostname or ""
if parts.port:
host = f"{host}:{parts.port}"
query = []
for key, item in parse_qsl(parts.query, keep_blank_values=True):
query.append((key, "[REDACTED]" if key.lower() in TOKEN_KEYS else item))
return urlunsplit((parts.scheme, host, parts.path, urlencode(query), parts.fragment))
def get(repo: pathlib.Path, *args: str) -> str | None:
code, out, _ = git(repo, *args)
return out if code == 0 and out else None
def parse_status(text: str) -> dict[str, int]:
result = {"staged": 0, "modified": 0, "untracked": 0, "conflicted": 0}
conflict_codes = {"DD", "AU", "UD", "UA", "DU", "AA", "UU"}
for line in text.splitlines():
if len(line) < 2:
continue
code = line[:2]
if code == "??":
result["untracked"] += 1
continue
if code in conflict_codes:
result["conflicted"] += 1
if code[0] not in {" ", "?"}:
result["staged"] += 1
if code[1] not in {" ", "?"}:
result["modified"] += 1
return result
def diagnose(repo: pathlib.Path, check_remotes: bool) -> dict[str, object]:
repo = repo.resolve()
code, root, error = git(repo, "rev-parse", "--show-toplevel")
if code != 0:
return {"ok": False, "path": str(repo), "error": error or "not a Git repository"}
root_path = pathlib.Path(root)
status_text = get(root_path, "status", "--porcelain=v1", "--untracked-files=all") or ""
branch = get(root_path, "branch", "--show-current")
detached = get(root_path, "rev-parse", "--short", "HEAD") if not branch else None
upstream = get(root_path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}")
ahead = behind = None
if upstream:
counts = get(root_path, "rev-list", "--left-right", "--count", f"HEAD...{upstream}")
if counts:
left, right = counts.split()
ahead, behind = int(left), int(right)
remotes: list[dict[str, object]] = []
for name in (get(root_path, "remote") or "").splitlines():
url = get(root_path, "remote", "get-url", name) or ""
item: dict[str, object] = {"name": name, "url": redact_url(url)}
if check_remotes:
remote_code, _, remote_error = git(
root_path, "ls-remote", "--exit-code", "--heads", name, timeout=30
)
item["reachable"] = remote_code == 0
if remote_code != 0:
item["error"] = re.sub(
r"(?i)(password|token|authorization)[^\s]*",
"[REDACTED]",
remote_error[:300],
)
remotes.append(item)
return {
"ok": True,
"root": str(root_path),
"branch": branch or f"detached@{detached or 'unknown'}",
"head": get(root_path, "rev-parse", "HEAD"),
"upstream": upstream,
"ahead": ahead,
"behind": behind,
"status": parse_status(status_text),
"author": {
"name": get(root_path, "config", "--get", "user.name"),
"email": get(root_path, "config", "--get", "user.email"),
},
"remotes": remotes,
"last_commit": get(root_path, "log", "-1", "--format=%h %ad %s", "--date=iso-strict"),
}
def render_human(report: dict[str, object]) -> str:
if not report.get("ok"):
return f"Git diagnosis failed: {report.get('error')} ({report.get('path')})"
lines = [
f"Repository: {report['root']}",
f"Branch: {report['branch']}",
f"HEAD: {report.get('head')}",
f"Upstream: {report.get('upstream') or '-'}",
f"Ahead/behind: {report.get('ahead')}/{report.get('behind')}",
]
status = report["status"]
lines.append(
"Changes: staged={staged}, modified={modified}, untracked={untracked}, "
"conflicted={conflicted}".format(**status)
)
author = report["author"]
lines.append(
f"Author: {author.get('name') or '[unset]'} <{author.get('email') or '[unset]'}>"
)
lines.append("Remotes:")
for remote in report["remotes"]:
suffix = ""
if "reachable" in remote:
suffix = " reachable" if remote["reachable"] else " unreachable"
lines.append(f" - {remote['name']}: {remote['url']}{suffix}")
lines.append(f"Last commit: {report.get('last_commit') or '-'}")
return "\n".join(lines)
def main() -> int:
parser = argparse.ArgumentParser(description="Read-only Git repository diagnostics.")
parser.add_argument("path", nargs="?", default=".")
parser.add_argument("--json", action="store_true", dest="as_json")
parser.add_argument("--check-remotes", action="store_true")
args = parser.parse_args()
report = diagnose(pathlib.Path(args.path), args.check_remotes)
print(json.dumps(report, ensure_ascii=False, indent=2) if args.as_json else render_human(report))
return 0 if report.get("ok") else 2
if __name__ == "__main__":
raise SystemExit(main())