From 2cddf06b6c4e324e0b0483b480adb70c8b8f2051 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=97=85=E8=A1=8C=E5=91=80=7E?= Date: Thu, 20 Aug 2026 23:42:02 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96:=20=E5=AE=8C?= =?UTF-8?q?=E6=88=90Git=20Skill=E6=B5=8B=E8=AF=95=E5=9F=BA=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 旅行呀~ --- .cursor/skills/git-init/SKILL.md | 311 ++++++++++++++++ .cursor/skills/git-init/examples.md | 66 ++++ .cursor/skills/git-init/gitea.md | 173 +++++++++ .cursor/skills/git-init/reference.md | 192 ++++++++++ .../git-init/scripts/assert-no-secrets.ps1 | 50 +++ .cursor/skills/git-init/scripts/common.ps1 | 291 +++++++++++++++ .cursor/skills/git-init/scripts/defaults.json | 4 + .../git-init/scripts/emit-commit-message.ps1 | 24 ++ .../skills/git-init/scripts/ensure-gpg.ps1 | 202 ++++++++++ .../skills/git-init/scripts/ensure-ssh.ps1 | 114 ++++++ .../git-init/scripts/inject-credential.ps1 | 112 ++++++ .../skills/git-init/scripts/prepare-auth.ps1 | 74 ++++ .../git-init/scripts/prepare-commit.ps1 | 57 +++ .../skills/git-init/scripts/prepare-push.ps1 | 26 ++ .../git-init/scripts/profile-status.ps1 | 23 ++ .../skills/git-init/scripts/repo-status.ps1 | 52 +++ .../skills/git-init/scripts/store-profile.ps1 | 106 ++++++ .../skills/git-init/scripts/store-token.ps1 | 42 +++ .cursor/skills/git-init/scripts/test-ssh.ps1 | 66 ++++ .../skills/git-init/scripts/token-status.ps1 | 34 ++ .../git-init/scripts/verify-gitea-keys.ps1 | 109 ++++++ .../git-init/scripts/verify-gpg-challenge.ps1 | 98 +++++ .../git-init/scripts/verify-ssh-challenge.ps1 | 81 ++++ .cursor/skills/git-init/workflows.md | 131 +++++++ .gitignore | 10 + README.md | 39 ++ docs/使用文档.md | 222 +++++++++++ docs/架构.md | 326 ++++++++++++++++ install.ps1 | 38 ++ 相关文档.md | 349 ++++++++++++++++++ 30 files changed, 3422 insertions(+) create mode 100644 .cursor/skills/git-init/SKILL.md create mode 100644 .cursor/skills/git-init/examples.md create mode 100644 .cursor/skills/git-init/gitea.md create mode 100644 .cursor/skills/git-init/reference.md create mode 100644 .cursor/skills/git-init/scripts/assert-no-secrets.ps1 create mode 100644 .cursor/skills/git-init/scripts/common.ps1 create mode 100644 .cursor/skills/git-init/scripts/defaults.json create mode 100644 .cursor/skills/git-init/scripts/emit-commit-message.ps1 create mode 100644 .cursor/skills/git-init/scripts/ensure-gpg.ps1 create mode 100644 .cursor/skills/git-init/scripts/ensure-ssh.ps1 create mode 100644 .cursor/skills/git-init/scripts/inject-credential.ps1 create mode 100644 .cursor/skills/git-init/scripts/prepare-auth.ps1 create mode 100644 .cursor/skills/git-init/scripts/prepare-commit.ps1 create mode 100644 .cursor/skills/git-init/scripts/prepare-push.ps1 create mode 100644 .cursor/skills/git-init/scripts/profile-status.ps1 create mode 100644 .cursor/skills/git-init/scripts/repo-status.ps1 create mode 100644 .cursor/skills/git-init/scripts/store-profile.ps1 create mode 100644 .cursor/skills/git-init/scripts/store-token.ps1 create mode 100644 .cursor/skills/git-init/scripts/test-ssh.ps1 create mode 100644 .cursor/skills/git-init/scripts/token-status.ps1 create mode 100644 .cursor/skills/git-init/scripts/verify-gitea-keys.ps1 create mode 100644 .cursor/skills/git-init/scripts/verify-gpg-challenge.ps1 create mode 100644 .cursor/skills/git-init/scripts/verify-ssh-challenge.ps1 create mode 100644 .cursor/skills/git-init/workflows.md create mode 100644 .gitignore create mode 100644 README.md create mode 100644 docs/使用文档.md create mode 100644 docs/架构.md create mode 100644 install.ps1 create mode 100644 相关文档.md diff --git a/.cursor/skills/git-init/SKILL.md b/.cursor/skills/git-init/SKILL.md new file mode 100644 index 0000000..a799457 --- /dev/null +++ b/.cursor/skills/git-init/SKILL.md @@ -0,0 +1,311 @@ +--- +name: git-init +description: >- + Complete Git workflows on Windows for Gitea (and other hosts): init, signed + Chinese commits, push, clone, pull, fetch, branch, merge, tag, stash, and + Gitea SSH/GPG key verification challenges, using a local author profile, + DPAPI-encrypted access token, SSH, and GPG. Use when the user asks to + 初始化 Git, git init, Gitea, 验证密钥, 验证SSH, 验证GPG, 找不到此签名对应的密钥, + 提交, commit, 推送, push, 克隆, clone, 拉取, pull, fetch, 分支, branch, 合并, + merge, 标签, tag, stash, 远程推送, 配置远程仓库, 配置 SSH, 配置 GPG, + 配置 Git Token, or uses the git-init skill. +--- + +# git-init + +Windows PowerShell. Default remote host is **Gitea** (self-hosted or public). Do not use `eval "$(ssh-agent -s)"`. See [gitea.md](gitea.md) for URL forms, Token, SSH, and GPG on Gitea. + +Resolve `scripts/`: + +1. Workspace `.cursor/skills/git-init/scripts` if it exists +2. Else `$env:USERPROFILE/.cursor/skills/git-init/scripts` +3. Else tell the user to run `install.ps1` from the git-skills repo and stop + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File "/.ps1" +``` + +Secrets live in `%USERPROFILE%/.git-skills/` (never in the project). + +## Security red lines + +- Never read, print, or log `token.dpapi` or the **HTTPS access token** +- Never put the HTTPS access token in chat, argv, README, `.env`, or git config +- Never ask the user to paste the **HTTPS access token** into the conversation; use `store-token.ps1` on their machine +- **Exception (Gitea key verify only):** you MAY ask for the short-lived **challenge token** shown on `/user/settings/keys` → 验证 (GPG and/or SSH). Do not store it. Do not confuse it with the access token +- Do not run `store-token.ps1` or `store-profile.ps1` in the agent session +- Never `--force` / `--force-with-lease` unless the user explicitly asked +- Never `--no-verify` / skip hooks; never change an existing remote +- Never set `--global` `user.name` / `user.email`. `credential.helper` may be global +- Commit only when the user asked to commit/提交; push only when they asked to push/推送 +- Never `reset --hard`, `rebase -i`, `push --delete`, or rewrite history unless the user explicitly asked + +## Route + +| User intent | Path | +| --- | --- | +| 初始化 / git init / 配置远程 / 首次提交 | **Init** | +| 提交 / commit | **Commit** (do not re-run init) | +| 推送 / push / 远程推送 | **Push** | +| 提交并推送 | **Commit** then **Push** | +| 克隆 / clone | **Clone** — [workflows.md](workflows.md) | +| 拉取 / pull / fetch | **Sync** — [workflows.md](workflows.md) | +| 分支 / 切换 / 新建分支 | **Branch** — [workflows.md](workflows.md) | +| 合并 / merge / rebase | **Merge** — [workflows.md](workflows.md) | +| 标签 / tag | **Tag** — [workflows.md](workflows.md) | +| stash / 暂存改动 | **Stash** — [workflows.md](workflows.md) | +| 状态 / log / diff / 现在 git 怎么样 | **Inspect** (`repo-status.ps1`) | +| 验证密钥 / 验证 SSH / 验证 GPG / 找不到此签名对应的密钥 / 开锁 | **Verify keys** (below) | + +If the folder is not a repo and the user asked to commit/push/pull/branch, run **Init** first (ask for remote URL if missing). Clone does not init the current folder. + +Examples: [examples.md](examples.md). + +## Shared: profile + token + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File "/profile-status.ps1" +powershell -NoProfile -ExecutionPolicy Bypass -File "/token-status.ps1" +``` + +- Profile `missing` → stop; user runs `store-profile.ps1` in their own terminal +- Token `missing`/`invalid` → required for **Init** and for **HTTPS Push**; for **SSH Push** continue without token. User runs `store-token.ps1` in their own terminal when needed + +Do not ask for name/email/HTTPS access token in chat if the profile exists. + +# Verify keys (Gitea SSH + GPG) + +Proactive path when the user wants key verification, or Init finished adding keys, or UI shows 「找不到此签名对应的密钥」. + +``` +- [ ] Explain: open /user/settings/keys, click 验证 (challenge token ≠ access token) +- [ ] Ask for GPG challenge token (and Key ID if unknown) +- [ ] Ask for SSH challenge token +- [ ] Run verify-gitea-keys.ps1 with tokens received +- [ ] Show signature blocks; user pastes back into Gitea → 验证 +- [ ] Remind: hard-refresh; only new commits show verified +``` + +**Ask in chat (required, do not wait for the user to invent the step):** + +1. GPG: 「请打开 Gitea → 设置 → SSH/GPG 密钥 → 对应 GPG → 点验证,把页面上的令牌发给我(这是一次性挑战令牌,不是访问令牌)」 +2. SSH: 「请再打开同一页的 SSH 密钥 → 点验证,把**另一个**页面令牌发给我」 + +When tokens arrive: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File "/ensure-gpg.ps1" -Name "" -Email "" +powershell -NoProfile -ExecutionPolicy Bypass -File "/verify-gitea-keys.ps1" -GpgToken "" -SshToken "" -KeyId "" +``` + +- Only GPG: `-GpgOnly -GpgToken "..."` +- Only SSH: `-SshOnly -SshToken "..."` +- Missing token → script prints `need_token` / `ask=...`; ask again and stop until answered + +Then paste the printed `BEGIN PGP SIGNATURE` / `BEGIN SSH SIGNATURE` blocks back to the user and tell them to click **验证** on Gitea. Do not claim verification succeeded until they confirm the UI shows 已验证. + +After both verified, ensure local signing: + +```powershell +git config user.signingkey "" +git config commit.gpgsign true +git config --unset gpg.format +``` + +Details: [gitea.md](gitea.md). + +# Init + +``` +- [ ] Detect +- [ ] Profile + token +- [ ] Remote URL + SSH/GPG +- [ ] git init + local author +- [ ] origin (do not overwrite) +- [ ] Inject credentials +- [ ] SSH +- [ ] GPG +- [ ] .gitignore + README +- [ ] First signed Chinese commit +- [ ] Push main +- [ ] Verify +``` + +Detect: + +```powershell +git rev-parse --is-inside-work-tree +git remote -v +``` + +If `origin` already exists: report remotes, switch to **Commit**/**Push** if that is what they wanted, and stop init. Do not change remotes. + +If the user did not give a remote URL, ask and wait. + +Default: reuse `~/.ssh/id_ed25519` and an existing GPG key. Generate only when missing. + +```powershell +git init -b main # skip if already a repo +``` + +Local author from `profile-status.ps1`: + +```powershell +git config user.name "" +git config user.email "" +git remote add origin "" +git config --global credential.helper manager # only if unset +powershell -NoProfile -ExecutionPolicy Bypass -File "/inject-credential.ps1" -RemoteUrl "" +powershell -NoProfile -ExecutionPolicy Bypass -File "/ensure-ssh.ps1" -Email "" +powershell -NoProfile -ExecutionPolicy Bypass -File "/test-ssh.ps1" -RemoteUrl "" +powershell -NoProfile -ExecutionPolicy Bypass -File "/ensure-gpg.ps1" -Name "" -Email "" +git config user.signingkey "" +git config commit.gpgsign true +``` + +Show the **public** SSH key. Tell the user to add it in Gitea: **设置 → SSH / GPG 密钥**. Run `test-ssh.ps1` (supports custom SSH ports from the URL). Status `ok` even if ssh exits 1 when the message contains `Hi there` / `successfully authenticated` / `Welcome to Gitea`. If auth fails, wait for the user to add the pubkey. + +**Gitea key verification is required** (official: unverified keys → 「找不到此签名对应的密钥」). After keys are added, go to **Verify keys**: **proactively ask** for the GPG and SSH challenge tokens from `/user/settings/keys` → 验证, then run `verify-gitea-keys.ps1`. Prefer OpenPGP for commits (`user.signingkey` = Key ID; do not set `gpg.format=ssh` unless the user wants SSH commit signing). Guide: [gitea.md](gitea.md). Official: https://docs.gitea.com/administration/signing/ + +Ensure `.gitignore` contains: + +``` +.git-skills/ +*.dpapi +.env +.env.* +*.pem +id_rsa +id_ed25519 +id_ecdsa +``` + +Create `README.md` only if missing (项目简介 / 基础使用说明 / 项目结构 / 开发说明). Do not overwrite. + +Then **Commit** with message `初始化: 完成项目Git配置` if there is no commit yet, then **Push** `main` (`git branch -M main` first). Verify with the Push/Commit verify steps. + +# Commit (every time) + +Do this for the first commit and every later commit. Do not re-init. + +``` +- [ ] prepare-commit +- [ ] GPG key if signing=missing +- [ ] status / diff / log +- [ ] assert-no-secrets +- [ ] Stage related files only +- [ ] Signed Chinese commit +- [ ] Verify signature +- [ ] Push only if asked +``` + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File "/prepare-commit.ps1" +``` + +If `signing=missing`: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File "/ensure-gpg.ps1" -Name "" -Email "" +git config user.signingkey "" +powershell -NoProfile -ExecutionPolicy Bypass -File "/prepare-commit.ps1" +``` + +Then: + +```powershell +git status +git diff +git diff --staged +git log -5 --oneline +powershell -NoProfile -ExecutionPolicy Bypass -File "/assert-no-secrets.ps1" +``` + +If `assert-no-secrets` is `blocked`, unstage those paths and do not commit them. + +Stage **only related files**. Do not `git add -A` on later commits unless the user asked to commit everything. Init may `git add -A` after `.gitignore` is in place. + +If there is nothing to commit, stop. Do not create an empty commit. + +Message must be Chinese `类型: 描述`. Types: `初始化` / `新增` / `修复` / `优化` / `文档`. Infer from the diff; if the user gave a message, rewrite it into this format. Forbidden: `update` / `fix` / `test` / `修改一下`. Details: [reference.md](reference.md). + +**Every commit must include `Co-authored-by` trailers** from the local profile (primary author always; plus optional `coAuthors`). Do not invent co-authors. Prefer profile author over any IDE default. If Cursor also appends `Co-authored-by: Cursor <...>`, keep the profile trailers from `emit-commit-message.ps1` (do not drop them). Build the message with: + +```powershell +$subject = "类型: 描述" +$msg = & powershell -NoProfile -ExecutionPolicy Bypass -File "/emit-commit-message.ps1" -Subject $subject | Out-String +git commit -S -m $msg.TrimEnd() +``` + +Equivalent shape (must keep a blank line before trailers): + +``` +类型: 描述 + +Co-authored-by: 旅行呀~ +``` + +Always `-S`. Never strip `Co-authored-by` lines. Never `--amend` unless the user asked and the HEAD commit is yours, unpushed, and no hook failed. + +```powershell +git status +git log --show-signature -1 +git log -1 --format=%B +``` + +Confirm the body contains `Co-authored-by:` and author is `姓名 <邮箱>` from the profile. If the user also asked to push, continue to **Push**. + +# Push (every time) + +Do this for the first push and every later push. Do not re-init. Do not commit during Push unless the user also asked to commit. + +``` +- [ ] prepare-push +- [ ] Push current branch +- [ ] Verify +``` + +```powershell +git status -sb +git remote -v +powershell -NoProfile -ExecutionPolicy Bypass -File "/prepare-push.ps1" +``` + +If `remote_missing`, ask for a URL and go to **Init** step “Add origin”, then retry. If HTTPS `token_missing`, stop and tell the user to run `store-token.ps1`. + +```powershell +git branch --show-current +git rev-parse --abbrev-ref --symbolic-full-name '@{u}' +``` + +- No upstream: `git push -u origin HEAD` +- Upstream exists: `git push` +- After init, `main` may not exist yet: `git branch -M main` then `git push -u origin main` +- Never `--force` unless explicitly requested (warn that it rewrites remote history) +- Never push to a different remote/branch than the user named + +```powershell +git status -sb +git log --show-signature -1 +``` + +# Clone / Sync / Branch / Merge / Tag / Stash / Inspect + +Read [workflows.md](workflows.md) and follow that path. Network ops always run auth first: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File "/repo-status.ps1" +powershell -NoProfile -ExecutionPolicy Bypass -File "/prepare-auth.ps1" -RemoteUrl "" +powershell -NoProfile -ExecutionPolicy Bypass -File "/prepare-push.ps1" +``` + +- **Clone**: `prepare-auth` then `git clone`; then `prepare-commit` inside the clone (do not commit unless asked) +- **Pull**: `prepare-push` then `git pull --ff-only`. If it fails, stop and report divergence; do not force +- **Inspect**: `repo-status.ps1` plus `git status` / `diff` / `log --show-signature`; read-only + +## Additional resources + +- Gitea URLs, Token, SSH/GPG verify: [gitea.md](gitea.md) +- Commit types, Windows SSH/GPG, troubleshooting: [reference.md](reference.md) +- Clone, pull, branch, merge, tag, stash, inspect: [workflows.md](workflows.md) +- Trigger examples: [examples.md](examples.md) diff --git a/.cursor/skills/git-init/examples.md b/.cursor/skills/git-init/examples.md new file mode 100644 index 0000000..e65db07 --- /dev/null +++ b/.cursor/skills/git-init/examples.md @@ -0,0 +1,66 @@ +# git-init 触发示例 + +默认远程为 **Gitea**。把主机名换成你的实例。 + +## 初始化 + +用户:用 git-init 初始化,远程是 `git@git.example.com:owner/repo.git` + +或:`https://git.example.com:3000/owner/repo.git` +或:`ssh://git@git.example.com:2222/owner/repo.git` + +路径:Init → 首次 Commit → Push main + +## 日常提交 + +用户:按 git-init 提交这些改动 + +路径:Commit(不 init)。说明含中文 `类型: 描述` + 档案中的 `Co-authored-by`。 + +## 日常推送 + +用户:推送到远程 / 推送到 Gitea + +路径:Push + +## 提交并推送 + +用户:提交并推送 + +路径:Commit → Push + +## 克隆 + +用户:用 git-init 克隆 `https://git.example.com/owner/repo.git` + +路径:Clone(`prepare-auth.ps1` → `git clone` → `prepare-commit.ps1`) + +## 拉取 + +用户:拉取远程更新 + +路径:Fetch/Pull(`--ff-only`) + +## 分支 + +用户:基于当前分支新建 `feature/login` 并切过去 + +路径:Branch `git switch -c feature/login` + +## 合并 + +用户:把 `feature/login` 合进 `main` + +路径:Merge + +## 查看 + +用户:现在 git 状态怎么样 + +路径:Inspect(`repo-status.ps1`,不提交) + +## Gitea 密钥验证(网页开锁 / 找不到签名密钥) + +用户:验证 Gitea 的 SSH 和 GPG / 提交显示找不到签名对应的密钥 + +路径:**Verify keys** — Agent 主动询问两个页面挑战令牌 → `verify-gitea-keys.ps1` → 把签名块发回让用户点「验证」。 diff --git a/.cursor/skills/git-init/gitea.md b/.cursor/skills/git-init/gitea.md new file mode 100644 index 0000000..6fde46f --- /dev/null +++ b/.cursor/skills/git-init/gitea.md @@ -0,0 +1,173 @@ +# Gitea:SSH / GPG 配置与验证 + +依据官方文档与 Gitea UI 源码整理(用户侧)。管理员侧签名见文末链接。 + +- 官方:[GPG/SSH Commit Signatures](https://docs.gitea.com/administration/signing/) +- 密钥页:`https://<你的Gitea>/user/settings/keys` +- 信任模型(管理员):`[repository.signing] DEFAULT_TRUST_MODEL`(`collaborator` / `committer` / `collaboratorcommitter`) + +## 网页图标含义(官方) + +| 图标 | 含义 | +| --- | --- | +| 灰色开锁 | **数据库中找不到**可用于校验的密钥(中文常见:「找不到此签名对应的密钥」) | +| 红色开锁 | 声称已签名,但密钥有问题 / 不可信 | +| 已验证锁 | 签名可被 Gitea 用库中**已验证**密钥校验,且符合仓库信任模型 | + +**只「添加」公钥不够。** SSH 用于提交签名识别、以及 GPG 显示已验证,通常都要求在设置页完成 **验证**(证明持有对应私钥)。见 [go-gitea/gitea#20597](https://github.com/go-gitea/gitea/issues/20597)。 + +## 远程 URL + +``` +https://git.example.com/owner/repo.git +https://git.example.com:3000/owner/repo.git +git@git.example.com:owner/repo.git +ssh://git@git.example.com:2222/owner/repo.git +``` + +HTTPS 非默认端口会写入 GCM `host=name:port`。详见 Skill 脚本 `Parse-GitRemoteUrl`。 + +## 一、SSH 密钥(推送 + 可选 SSH 签名) + +### 1. 本机生成 / 复用 + +Skill:`ensure-ssh.ps1`(默认 `~/.ssh/id_ed25519`)。 + +### 2. 添加到 Gitea + +1. **设置** → **SSH / GPG 密钥** → **增加密钥** +2. 粘贴**公钥**(`.pub` 一整行) +3. 保存 + +### 3. 验证 SSH 密钥(必须,否则 SSH 签名提交会「找不到密钥」) + +1. 在该密钥旁点 **验证** +2. 复制页面令牌(每次不同,勿写入仓库) +3. 官方 UI 给出的命令形态(见 `keys_ssh.tmpl`): + +```bash +echo -n 'TOKEN' | ssh-keygen -Y sign -n gitea -f /path/to/id_ed25519 +``` + +Windows 推荐用 Skill 脚本(避免 PowerShell/`echo` 编码问题): + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File "/verify-ssh-challenge.ps1" ` + -Token "TOKEN_FROM_PAGE" ` + -KeyPath "$env:USERPROFILE\.ssh\id_ed25519" +``` + +4. 将输出的 `-----BEGIN SSH SIGNATURE-----` … 整段贴回页面 → **验证** +5. 成功后应出现「已验证」标记 + +也可用公钥路径(agent 持有私钥时):`-KeyPath` 指向 `.pub` 亦可(OpenSSH 会经 agent 签名)。 + +### 4. 测试推送认证(与「签名验证」不同) + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File "/test-ssh.ps1" -RemoteUrl "" +``` + +## 二、GPG 密钥(本 Skill 默认的提交签名方式) + +### 1. 本机生成 / 复用 + +Skill:`ensure-gpg.ps1`。导出公钥: + +```powershell +& "$env:ProgramFiles\Git\usr\bin\gpg.exe" --armor --export KEY_ID +``` + +### 2. 添加到 Gitea + +1. **设置** → **SSH / GPG 密钥** → GPG 区 **增加密钥** +2. 粘贴 ASCII 公钥(`BEGIN PGP PUBLIC KEY BLOCK`) +3. 保存;确认 **匹配身份** 含 Gitea 账号已验证邮箱(如 `travelxiao@qq.com`) + +### 3. 验证 GPG 密钥(必须) + +官方 UI 命令形态(见 `keys_gpg.tmpl`): + +```bash +echo "TOKEN" | gpg -a --default-key KEY_ID --detach-sig +``` + +注意:这里是 **带换行** 的 `echo "TOKEN"`(与 SSH 的 `echo -n` 不同)。 + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File "/verify-gpg-challenge.ps1" ` + -Token "TOKEN_FROM_PAGE" ` + -KeyId "5996DA789B43D451" +``` + +将 `-----BEGIN PGP SIGNATURE-----` … 贴回 → **验证**。 + +### 4. 本地 Git 使用同一把 GPG 钥签名 + +```powershell +git config user.signingkey KEY_ID # 仓库 local;或按需 --global +git config commit.gpgsign true +# 使用 GPG 时不要设置 gpg.format=ssh +git config --unset gpg.format # 若曾设为 ssh +``` + +Skill 的 `prepare-commit.ps1` / Init 流程会设置 local `user.signingkey` 与 `commit.gpgsign`。 + +## 三、排错:「找不到此签名对应的密钥」 + +按官方说明,灰色开锁 = Gitea **库中没有**可用来校验该签名的密钥。常见原因: + +1. 公钥已添加但 **未验证** → 先完成上一节验证 +2. 本地 `user.signingkey` / `gpg.format` 与网页上的密钥不是同一把 + - GPG:`gpg.format` 未设或为 openpgp,`signingkey` = 网页 Key ID + - SSH 签名:`gpg.format=ssh`,且对应 SSH 密钥已在 Gitea **验证** +3. 提交作者邮箱不在 Gitea 账号邮箱列表 / 与 GPG uid 不一致 → 在 Gitea 添加并验证该邮箱 +4. 仓库 **信任模型**(`DEFAULT_TRUST_MODEL`)导致虽能识别密钥但不显示为可信(管理员配置;默认常为 `collaborator`) +5. 验证成功后请 **硬刷新**(Ctrl+F5);**旧提交**可能仍显示开锁,新提交才变绿 + +检查本机: + +```powershell +git config --get user.signingkey +git config --get gpg.format +git config --get user.email +git log -1 --show-signature +``` + +## 四、Token(HTTPS 推送) + +与密钥验证令牌不同:这是 **访问令牌**(设置 → 应用)。 + +- 用户名:Gitea 登录名(档案 `gitUsername`) +- 密码:访问令牌 +- 本机:`store-token.ps1`(DPAPI),禁止发到对话 + +## 五、Agent 流程(密钥验证 — 主动询问) + +HTTPS **访问令牌**仍禁止进对话。Gitea 页面上的 **验证挑战令牌**(一次性)允许 Agent 主动索取。 + +1. `ensure-ssh.ps1` / `ensure-gpg.ps1` → 用户把公钥加到 Gitea +2. Agent **主动询问**: + - GPG 验证页令牌 + - SSH 验证页令牌(与 GPG 不同) +3. 收到后执行: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File "/verify-gitea-keys.ps1" ` + -GpgToken "" -SshToken "" -KeyId "" +``` + +4. 把生成的签名块发给用户,指导贴回 Gitea → **验证** +5. 用户确认「已验证」后,本地 `commit.gpgsign` + `user.signingkey` +6. `test-ssh.ps1` 测推送通道 + +缺令牌时脚本输出 `need_token` / `waiting_for_tokens`,Agent 继续追问,不要跳过。 + +## 六、管理员参考(非本机 Skill 必做) + +服务器可用 `[repository.signing]` 让 Gitea 自己签 merge 等提交;见官方文档。用户侧密钥验证不依赖该项,但信任模型会影响「已验证」展示。 + +官方文档: + +- https://docs.gitea.com/administration/signing/ +- https://docs.gitea.com/administration/config-cheat-sheet/(`DEFAULT_TRUST_MODEL` 等) diff --git a/.cursor/skills/git-init/reference.md b/.cursor/skills/git-init/reference.md new file mode 100644 index 0000000..9340cb6 --- /dev/null +++ b/.cursor/skills/git-init/reference.md @@ -0,0 +1,192 @@ +# git-init 参考 + +SKILL.md 未覆盖的细节。Agent 只在需要时阅读本文件。Gitea 见 [gitea.md](gitea.md);克隆/拉取/分支/合并见 [workflows.md](workflows.md)。 + +## 中文提交规范 + +所有提交信息必须使用中文。 + +格式: + +``` +类型: 描述 + +Co-authored-by: 姓名 <邮箱> +``` + +每次提交**必须**带 `Co-authored-by`(Git trailer)。主体与 trailer 之间空一行。 + +- 默认:档案中的 `userName` / `userEmail`(例如 `Co-authored-by: 旅行呀~ `) +- 额外合作者:档案字段 `coAuthors`(`store-profile.ps1` 可追加 `Name `) +- 生成完整说明:`emit-commit-message.ps1 -Subject "类型: 描述"` + +示例: + +``` +初始化: 完成项目Git配置 + +Co-authored-by: 旅行呀~ +``` + +``` +新增: 添加用户模块 + +Co-authored-by: 旅行呀~ +``` + +禁止: + +``` +update + +fix + +test + +修改一下 +``` + +后续提交作者(`git` Author)必须为本地档案中的 `姓名 <邮箱>`,且说明里含对应 `Co-authored-by`。每次提交必须包含清晰描述,例如: + +``` +新增: 添加支付接口 + +Co-authored-by: 旅行呀~ +``` + +签名提交使用 `git commit -S`。仓库 local 配置 `commit.gpgsign=true` 后,普通 `git commit` 也会签名。 + +## 日常提交 + +每次提交都走 `prepare-commit.ps1`,不要重新 `git init`。 + +1. `prepare-commit.ps1` 把档案中的 `user.name` / `user.email` 写到**当前仓库**,并打开 `commit.gpgsign`;输出 `co_authored_by=` +2. `signing=missing` 时再跑 `ensure-gpg.ps1`,写入 `user.signingkey` +3. `git status` / `git diff` / `git log -5` 后再写中文 `类型: 描述` +4. `assert-no-secrets.ps1` 失败则不得提交匹配路径 +5. 后续提交只暂存相关文件,不要默认 `git add -A` +6. 用 `emit-commit-message.ps1` 生成带 `Co-authored-by` 的说明,再 `git commit -S -m ...` +7. `git log -1 --format=%B` 确认含 `Co-authored-by:` +8. 用户没说推送就不要 `git push` + +无变更时不要空提交。 + +## 日常推送 + +每次推送都走 `prepare-push.ps1`,不要重新初始化。 + +1. 从 `origin` 解析 URL;HTTPS 静默注入 Token;SSH 则 `ssh-add`(不生成新密钥、不打印公钥) +2. 当前分支无上游:`git push -u origin HEAD` +3. 已有上游:`git push` +4. 禁止 `--force`,除非用户明确要求 +5. URL 里若带账号密码,脚本输出会打码,Agent 也不得把完整带密 URL 发到对话里 + +## Windows SSH + +不要使用: + +```bash +eval "$(ssh-agent -s)" +``` + +Windows 使用 OpenSSH 服务: + +```powershell +Get-Service ssh-agent +Set-Service ssh-agent -StartupType Manual +Start-Service ssh-agent +ssh-add $env:USERPROFILE\.ssh\id_ed25519 +``` + +`ensure-ssh.ps1` 会尝试启动服务并 `ssh-add`。`Set-Service` / `Start-Service` 在服务被禁用时可能需要管理员权限;失败时仍展示公钥,让用户手动添加。 + +公钥路径:`.pub`。只把公钥贴到 **Gitea → 设置 → SSH / GPG 密钥**。私钥不得出现在聊天或仓库里。 + +测试(推荐脚本,支持自建端口): + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File "/test-ssh.ps1" -RemoteUrl "git@git.example.com:owner/repo.git" +powershell -NoProfile -ExecutionPolicy Bypass -File "/test-ssh.ps1" -RemoteUrl "ssh://git@git.example.com:2222/owner/repo.git" +``` + +手写等价命令: + +```powershell +ssh -T git@git.example.com +ssh -p 2222 -T git@git.example.com +``` + +Gitea 成功时常见输出含 `Hi there` / `successfully authenticated` / `Welcome to Gitea`,退出码可能为 1。无 shell 权限是正常的。 + +完整菜单与 Token 步骤见 [gitea.md](gitea.md)。 + +若 `ssh-keygen` 不存在:安装 Git for Windows 或 Windows OpenSSH 可选功能。 + +## GPG + +需要本机 `gpg`(Gpg4win 或 Git 自带)。`ensure-gpg.ps1` 会按档案姓名/邮箱查找已有密钥;没有则 batch 生成 Ed25519,失败再回退 RSA 4096。生成密钥使用 `%no-protection`(无口令),便于 Cursor 非交互签名。若用户需要口令保护,应自行用 `gpg --full-generate-key` 创建,再把 key id 交给本流程。 + +列出密钥: + +```powershell +gpg --list-secret-keys --keyid-format LONG +``` + +Git 配置(仅当前仓库): + +```powershell +git config user.signingkey KEY_ID +git config commit.gpgsign true +``` + +将公钥添加到 **Gitea → 设置 → SSH / GPG 密钥**。否则网页上可能不显示已验证,但本地签名仍然有效。详见 [gitea.md](gitea.md)。 + +## Token 与凭据 + +Token 只存在 `%USERPROFILE%\.git-skills\token.dpapi`,DPAPI 绑定当前 Windows 用户。换机器或换用户无法解密。 + +- 写入:用户本机运行 `store-token.ps1`(隐藏输入) +- 状态:`token-status.ps1` → `present` / `missing` / `invalid` +- 注入:`inject-credential.ps1 -RemoteUrl ` 通过 `git credential approve` 交给 Git Credential Manager,stdout 不含 Token + +全局可设: + +```powershell +git config --global credential.helper manager +``` + +HTTPS 远程:用户名为档案中的 `gitUsername`(Gitea 登录名),密码为访问令牌。非默认端口会写入 GCM(如 `host=git.example.com:3000`)。SSH 远程:推送走 SSH 密钥;脚本仍会为同一主机注入 HTTPS 凭据(不含 SSH 端口)。详见 [gitea.md](gitea.md)。 + +## 故障排查 + +| 现象 | 处理 | +| --- | --- | +| `profile=missing` | 用户本机运行 `store-profile.ps1` | +| `token=missing` / `invalid` | 用户本机运行 `store-token.ps1`(`invalid` 表示无法用当前用户 DPAPI 解密) | +| `git_username_missing` | 重新运行 `store-profile.ps1` 并填写 HTTPS 用户名 | +| `git_credential_approve_failed` | 确认已安装 Git for Windows / GCM,并设置 `credential.helper=manager` | +| `ssh_keygen_missing` | 安装 OpenSSH 或 Git for Windows | +| `ssh_agent=error` 或 `ssh_add=failed` | 以管理员启用 `ssh-agent` 服务,或用户手动 `ssh-add` | +| `Permission denied (publickey)` | 把 `public_key=` 贴到平台后再 `ssh -T` | +| `gpg_missing` | 安装 [Gpg4win](https://www.gpg4win.org/) 并重新打开终端 | +| `gpg_generate_failed` | 检查 gpg 是否可用;或用户交互生成后再跑 `ensure-gpg.ps1` | +| push 认证失败(HTTPS) | 确认 Token 对应该 host 且未过期;重新 `store-token` + `inject-credential` | +| 已有 `origin` | 不要改 remote;改为提交/推送或让用户决定 | +| `git log --show-signature` 无签名 | 确认 `commit.gpgsign=true` 且用了 `-S`;GPG 密钥存在 | +| `not_a_git_repo` | 先走初始化,或确认当前目录是仓库 | +| `remote_missing` | 用户提供 URL 后 `git remote add origin` | +| `secret_paths` | 从暂存区移除脚本列出的路径后再提交 | +| `ssh_key_missing`(日常推送) | 走初始化 SSH,或用户指定已有密钥路径 | +| `test-ssh` 失败 | 公钥是否已加到 Gitea;URL 端口是否正确;本机是否已接受 host key | +| HTTPS 自建端口失败 | 确认 URL 含 `:3000` 等端口;GCM 的 host 应为 `host:port` | +| 灰色开锁 / 找不到此签名对应的密钥 | 按官方:库中无可用密钥。完成 Gitea **验证**;对齐 `signingkey`;邮箱一致;见 [gitea.md](gitea.md) | +| GPG 验证签名不被接受 | 必须用 `echo "TOKEN"`(有换行);用 `verify-gpg-challenge.ps1`,勿用 SSH 的 `echo -n` | +| SSH 验证签名不被接受 | 必须用 `echo -n`(无换行);用 `verify-ssh-challenge.ps1` 或页面上的 PowerShell/`cmd` 提示 | + +## 硬限制 + +- 不 force push +- 不修改已有 remote +- 不把 Token、私钥、`.dpapi` 写入仓库 +- 不把 Token 放进命令行参数或对话 +- 不修改全局 `user.name` / `user.email` diff --git a/.cursor/skills/git-init/scripts/assert-no-secrets.ps1 b/.cursor/skills/git-init/scripts/assert-no-secrets.ps1 new file mode 100644 index 0000000..347ccb3 --- /dev/null +++ b/.cursor/skills/git-init/scripts/assert-no-secrets.ps1 @@ -0,0 +1,50 @@ +# Agent-safe: refuse to proceed if secret-looking paths are staged or untracked. + +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'common.ps1') + +$inside = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('rev-parse', '--is-inside-work-tree') | Out-String).Trim() +if ($LASTEXITCODE -ne 0 -or $inside -ne 'true') { + Write-StatusLine -Key 'status' -Value 'failed' + Write-StatusLine -Key 'reason' -Value 'not_a_git_repo' + exit 1 +} + +$patterns = @( + '\.dpapi$', + '(^|/)token\.txt$', + '(^|/)\.env$', + '(^|/)\.env\.', + '\.pem$', + '(^|/)id_rsa$', + '(^|/)id_ed25519$', + '(^|/)id_ecdsa$', + '(^|/)\.git-skills/' +) + +$blocked = New-Object System.Collections.Generic.List[string] +$lines = Invoke-GitSkillsNative -FilePath git -ArgumentList @('status', '--porcelain', '-uall') +foreach ($line in $lines) { + if ([string]::IsNullOrWhiteSpace($line) -or $line.Length -lt 4) { continue } + $path = $line.Substring(3).Trim().Trim('"') + $path = $path -replace ' -> .+$', '' + $norm = $path.Replace('\', '/') + foreach ($re in $patterns) { + if ($norm -match $re) { + $blocked.Add($norm) | Out-Null + break + } + } +} + +if ($blocked.Count -gt 0) { + Write-StatusLine -Key 'status' -Value 'blocked' + Write-StatusLine -Key 'reason' -Value 'secret_paths' + foreach ($p in $blocked) { + Write-StatusLine -Key 'path' -Value $p + } + exit 1 +} + +Write-StatusLine -Key 'status' -Value 'ok' +exit 0 diff --git a/.cursor/skills/git-init/scripts/common.ps1 b/.cursor/skills/git-init/scripts/common.ps1 new file mode 100644 index 0000000..51977a8 --- /dev/null +++ b/.cursor/skills/git-init/scripts/common.ps1 @@ -0,0 +1,291 @@ +# Shared helpers for git-init skill scripts. +# Dot-source only. Never prints token values. + +$ErrorActionPreference = 'Stop' +if (Get-Variable -Name PSNativeCommandUseErrorActionPreference -ErrorAction SilentlyContinue) { + $PSNativeCommandUseErrorActionPreference = $false +} + + +# Windows PowerShell 5.1 promotes native stderr to a terminating error when +# $ErrorActionPreference='Stop', so 2>$null alone is not enough. This helper +# runs a native executable with stderr discarded and $LASTEXITCODE preserved. +function Invoke-GitSkillsNative { + param( + [Parameter(Mandatory = $true)] + [string]$FilePath, + [string[]]$ArgumentList = @() + ) + $prevEap = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + try { + $output = @(& $FilePath @ArgumentList 2>$null | ForEach-Object { "$_" }) + return ,$output + } + finally { + $ErrorActionPreference = $prevEap + } +} + +function Invoke-GitSkillsNativeWithInput { + param( + [Parameter(Mandatory = $true)] + [string]$FilePath, + [string[]]$ArgumentList = @(), + [Parameter(Mandatory = $true)] + [string]$InputText + ) + + $psi = New-Object System.Diagnostics.ProcessStartInfo + $psi.FileName = $FilePath + $psi.UseShellExecute = $false + $psi.RedirectStandardInput = $true + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.CreateNoWindow = $true + $psi.Arguments = ($ArgumentList | ForEach-Object { + if ($_ -match '\s|"') { '"{0}"' -f ($_ -replace '"', '\"') } else { $_ } + }) -join ' ' + + $proc = New-Object System.Diagnostics.Process + $proc.StartInfo = $psi + [void]$proc.Start() + foreach ($line in ($InputText -split "`r?`n", 0)) { + $proc.StandardInput.WriteLine($line) + } + $proc.StandardInput.Close() + $stdout = $proc.StandardOutput.ReadToEnd() + $stderr = $proc.StandardError.ReadToEnd() + $proc.WaitForExit() + $script:LastNativeExitCode = $proc.ExitCode + + if ($stderr) { $null = $stderr } + if ([string]::IsNullOrWhiteSpace($stdout)) { return @() } + return @($stdout -split "`r?`n") +} + +Add-Type -AssemblyName System.Security | Out-Null + +$script:GitSkillsHome = Join-Path $env:USERPROFILE '.git-skills' +$script:ProfilePath = Join-Path $script:GitSkillsHome 'profile.json' +$script:TokenPath = Join-Path $script:GitSkillsHome 'token.dpapi' +$script:DefaultsPath = Join-Path $PSScriptRoot 'defaults.json' + +function Get-GitSkillsDefaults { + if (-not (Test-Path -LiteralPath $script:DefaultsPath)) { + return [pscustomobject]@{ + userName = 'Traveler' + userEmail = 'user@example.com' + } + } + return Get-Content -LiteralPath $script:DefaultsPath -Raw -Encoding UTF8 | ConvertFrom-Json +} + +function Ensure-GitSkillsHome { + if (-not (Test-Path -LiteralPath $script:GitSkillsHome)) { + New-Item -ItemType Directory -Path $script:GitSkillsHome -Force | Out-Null + } +} + +function Get-GitSkillsProfile { + if (-not (Test-Path -LiteralPath $script:ProfilePath)) { + return $null + } + return Get-Content -LiteralPath $script:ProfilePath -Raw -Encoding UTF8 | ConvertFrom-Json +} + +function Save-GitSkillsProfile { + param( + [Parameter(Mandatory = $true)] + $Profile + ) + Ensure-GitSkillsHome + $json = $Profile | ConvertTo-Json -Depth 5 + $utf8Bom = New-Object System.Text.UTF8Encoding $true + [System.IO.File]::WriteAllText($script:ProfilePath, $json, $utf8Bom) +} + +function Protect-SecretString { + param( + [Parameter(Mandatory = $true)] + [string]$PlainText + ) + $bytes = [System.Text.Encoding]::UTF8.GetBytes($PlainText) + return [System.Security.Cryptography.ProtectedData]::Protect( + $bytes, + $null, + [System.Security.Cryptography.DataProtectionScope]::CurrentUser + ) +} + +function Unprotect-SecretBytes { + param( + [Parameter(Mandatory = $true)] + [byte[]]$Data + ) + $bytes = [System.Security.Cryptography.ProtectedData]::Unprotect( + $Data, + $null, + [System.Security.Cryptography.DataProtectionScope]::CurrentUser + ) + return [System.Text.Encoding]::UTF8.GetString($bytes) +} + +function ConvertFrom-SecureStringPlain { + param( + [Parameter(Mandatory = $true)] + [System.Security.SecureString]$Secure + ) + $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($Secure) + try { + return [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) + } + finally { + [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) + } +} + +function Parse-GitRemoteUrl { + param( + [Parameter(Mandatory = $true)] + [string]$RemoteUrl + ) + $url = $RemoteUrl.Trim() + if ([string]::IsNullOrWhiteSpace($url)) { + throw 'Remote URL is empty.' + } + + function New-ParsedRemote { + param( + [string]$Protocol, + [string]$HostName, + [object]$Port, + [string]$Path + ) + $portNum = $null + if ($null -ne $Port -and "$Port" -ne '') { + $portNum = [int]$Port + } + + $credentialHost = $HostName + if ($Protocol -eq 'https' -or $Protocol -eq 'http') { + $defaultPort = if ($Protocol -eq 'https') { 443 } else { 80 } + if ($null -ne $portNum -and $portNum -ne $defaultPort) { + $credentialHost = '{0}:{1}' -f $HostName, $portNum + } + } + + return [pscustomobject]@{ + Protocol = $Protocol + Host = $HostName + Port = $portNum + Path = $Path + CredentialHost = $credentialHost + } + } + + # Gitea / Git SCP: git@host:owner/repo.git + if ($url -match '^git@([^:]+):(.+)$') { + return New-ParsedRemote -Protocol 'ssh' -HostName $Matches[1] -Port $null -Path $Matches[2] + } + + # ssh://git@host:2222/owner/repo.git or ssh://git@host/owner/repo.git + if ($url -match '^ssh://(?:[^@/]+@)?(\[[^\]]+\]|[^:/]+)(?::(\d+))?/(.+)$') { + $port = if ($Matches[2]) { [int]$Matches[2] } else { $null } + return New-ParsedRemote -Protocol 'ssh' -HostName $Matches[1].Trim('[]') -Port $port -Path $Matches[3] + } + + try { + $uri = [Uri]$url + } + catch { + throw "Unsupported remote URL: $url" + } + + if (-not $uri.IsAbsoluteUri -or [string]::IsNullOrWhiteSpace($uri.Host)) { + throw "Unsupported remote URL: $url" + } + + $port = $null + if (-not $uri.IsDefaultPort) { + $port = $uri.Port + } + + return New-ParsedRemote -Protocol $uri.Scheme -HostName $uri.Host -Port $port -Path $uri.AbsolutePath.TrimStart('/') +} + +function Write-StatusLine { + param( + [Parameter(Mandatory = $true)] + [string]$Key, + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [string]$Value + ) + Write-Output ("{0}={1}" -f $Key, $Value) +} + +function Get-CoAuthorTrailerLines { + param( + $GitProfile = $null + ) + if ($null -eq $GitProfile) { + $GitProfile = Get-GitSkillsProfile + } + if ($null -eq $GitProfile) { + return @() + } + + $lines = New-Object System.Collections.Generic.List[string] + $primaryName = ([string]$GitProfile.userName).Trim() + $primaryEmail = ([string]$GitProfile.userEmail).Trim() + if (-not [string]::IsNullOrWhiteSpace($primaryName) -and -not [string]::IsNullOrWhiteSpace($primaryEmail)) { + [void]$lines.Add(('Co-authored-by: {0} <{1}>' -f $primaryName, $primaryEmail)) + } + + $extras = @() + if ($GitProfile.PSObject.Properties.Name -contains 'coAuthors' -and $null -ne $GitProfile.coAuthors) { + $extras = @($GitProfile.coAuthors) + } + foreach ($item in $extras) { + $name = '' + $email = '' + if ($null -eq $item) { continue } + if ($item -is [string]) { + if ($item -match '^\s*(.+?)\s*<([^>]+)>\s*$') { + $name = $Matches[1].Trim() + $email = $Matches[2].Trim() + } + } + else { + $name = ([string]$item.name).Trim() + $email = ([string]$item.email).Trim() + } + if ([string]::IsNullOrWhiteSpace($name) -or [string]::IsNullOrWhiteSpace($email)) { + continue + } + $line = 'Co-authored-by: {0} <{1}>' -f $name, $email + if (-not $lines.Contains($line)) { + [void]$lines.Add($line) + } + } + + return @($lines) +} + +function Format-CommitMessageWithTrailers { + param( + [Parameter(Mandatory = $true)] + [string]$Subject, + $GitProfile = $null + ) + $subject = $Subject.Trim() + if ([string]::IsNullOrWhiteSpace($subject)) { + throw 'Commit subject is empty.' + } + $trailers = Get-CoAuthorTrailerLines -GitProfile $GitProfile + if ($trailers.Count -eq 0) { + return $subject + } + return ($subject + "`n`n" + ($trailers -join "`n")) +} diff --git a/.cursor/skills/git-init/scripts/defaults.json b/.cursor/skills/git-init/scripts/defaults.json new file mode 100644 index 0000000..9550b23 --- /dev/null +++ b/.cursor/skills/git-init/scripts/defaults.json @@ -0,0 +1,4 @@ +{ + "userName": "旅行呀~", + "userEmail": "travelxiao@qq.com" +} diff --git a/.cursor/skills/git-init/scripts/emit-commit-message.ps1 b/.cursor/skills/git-init/scripts/emit-commit-message.ps1 new file mode 100644 index 0000000..3ac0a26 --- /dev/null +++ b/.cursor/skills/git-init/scripts/emit-commit-message.ps1 @@ -0,0 +1,24 @@ +# Agent-safe: build a commit message with required Co-authored-by trailers. +# Prints the full message only (no key=value). Never prints tokens. + +param( + [Parameter(Mandatory = $true)] + [string]$Subject +) + +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'common.ps1') + +$gitProfile = Get-GitSkillsProfile +if ($null -eq $gitProfile) { + Write-Error 'profile_missing' + exit 1 +} + +$msg = Format-CommitMessageWithTrailers -Subject $Subject -GitProfile $gitProfile +# Use LF so Git trailers parse consistently on Windows. +$msg = $msg -replace "`r`n", "`n" +[Console]::Out.Write($msg) +if (-not $msg.EndsWith("`n")) { + [Console]::Out.Write("`n") +} diff --git a/.cursor/skills/git-init/scripts/ensure-gpg.ps1 b/.cursor/skills/git-init/scripts/ensure-gpg.ps1 new file mode 100644 index 0000000..bce4ede --- /dev/null +++ b/.cursor/skills/git-init/scripts/ensure-gpg.ps1 @@ -0,0 +1,202 @@ +# Detect or generate a GPG signing key. Print key id only. Never export secret keys. + +param( + [string]$Name, + [string]$Email, + [switch]$ForceGenerate +) + +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'common.ps1') + +function Resolve-Gpg { + $cmd = Get-Command gpg -ErrorAction SilentlyContinue + if ($cmd) { return $cmd.Source } + + $candidates = @( + (Join-Path $env:ProgramFiles 'GnuPG\bin\gpg.exe'), + (Join-Path ${env:ProgramFiles(x86)} 'GnuPG\bin\gpg.exe'), + (Join-Path $env:ProgramFiles 'Git\usr\bin\gpg.exe') + ) + foreach ($path in $candidates) { + if ($path -and (Test-Path -LiteralPath $path)) { + return $path + } + } + return $null +} + +function Get-SecretKeyForEmail { + param( + [Parameter(Mandatory = $true)] + [string]$GpgPath, + [Parameter(Mandatory = $true)] + [string]$EmailAddress + ) + + $prevEap = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + try { + $lines = Invoke-GitSkillsNative -FilePath $GpgPath -ArgumentList @('--batch', '--list-secret-keys', '--with-colons') + } + finally { + $ErrorActionPreference = $prevEap + } + + $currentId = '' + $currentFpr = '' + $matchedId = '' + $matchedFpr = '' + + foreach ($line in $lines) { + $parts = $line.Split(':') + if ($parts.Count -lt 2) { continue } + $type = $parts[0] + + if ($type -eq 'sec' -or $type -eq 'ssb') { + if ($type -eq 'sec') { + $currentId = $parts[4] + $currentFpr = '' + } + } + elseif ($type -eq 'fpr' -and $parts.Count -ge 10) { + $fpr = $parts[9] + if ([string]::IsNullOrWhiteSpace($currentFpr)) { + $currentFpr = $fpr + if ([string]::IsNullOrWhiteSpace($currentId) -and $fpr.Length -ge 16) { + $currentId = $fpr.Substring($fpr.Length - 16) + } + } + } + elseif ($type -eq 'uid') { + $uid = if ($parts.Count -ge 10) { $parts[9] } else { '' } + if ($uid -match [regex]::Escape($EmailAddress)) { + $matchedId = $currentId + $matchedFpr = $currentFpr + break + } + } + } + + if ([string]::IsNullOrWhiteSpace($matchedId)) { + return $null + } + + return [pscustomobject]@{ + KeyId = $matchedId + Fingerprint = $matchedFpr + } +} + +function New-BatchKey { + param( + [Parameter(Mandatory = $true)] + [string]$GpgPath, + [Parameter(Mandatory = $true)] + [string]$RealName, + [Parameter(Mandatory = $true)] + [string]$EmailAddress, + [Parameter(Mandatory = $true)] + [ValidateSet('ed25519', 'rsa4096')] + [string]$Kind + ) + + if ($Kind -eq 'ed25519') { + $body = @" +%echo generating +Key-Type: EDDSA +Key-Curve: Ed25519 +Key-Usage: sign +Subkey-Type: ECDH +Subkey-Curve: Curve25519 +Subkey-Usage: encrypt +Name-Real: $RealName +Name-Email: $EmailAddress +Expire-Date: 0 +%no-protection +%commit +%echo done +"@ + } + else { + $body = @" +%echo generating +Key-Type: RSA +Key-Length: 4096 +Key-Usage: sign +Subkey-Type: RSA +Subkey-Length: 4096 +Subkey-Usage: encrypt +Name-Real: $RealName +Name-Email: $EmailAddress +Expire-Date: 0 +%no-protection +%commit +%echo done +"@ + } + + $temp = [System.IO.Path]::GetTempFileName() + try { + $utf8 = New-Object System.Text.UTF8Encoding $false + [System.IO.File]::WriteAllText($temp, $body, $utf8) + Invoke-GitSkillsNative -FilePath $GpgPath -ArgumentList @('--batch', '--generate-key', $temp) | Out-Null + return ($LASTEXITCODE -eq 0) + } + finally { + if (Test-Path -LiteralPath $temp) { + Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue + } + } +} + +$gitProfile = Get-GitSkillsProfile +$defaults = Get-GitSkillsDefaults + +if ([string]::IsNullOrWhiteSpace($Name)) { + if ($gitProfile) { $Name = [string]$gitProfile.userName } +} +if ([string]::IsNullOrWhiteSpace($Email)) { + if ($gitProfile) { $Email = [string]$gitProfile.userEmail } +} +if ([string]::IsNullOrWhiteSpace($Name)) { $Name = [string]$defaults.userName } +if ([string]::IsNullOrWhiteSpace($Email)) { $Email = [string]$defaults.userEmail } + +$gpg = Resolve-Gpg +if ($null -eq $gpg) { + Write-StatusLine -Key 'status' -Value 'failed' + Write-StatusLine -Key 'reason' -Value 'gpg_missing' + Write-Output 'hint=Install Gpg4win, then re-run ensure-gpg.ps1' + exit 1 +} + +$existing = Get-SecretKeyForEmail -GpgPath $gpg -EmailAddress $Email +if ($existing -and -not $ForceGenerate) { + Write-StatusLine -Key 'status' -Value 'exists' + Write-StatusLine -Key 'key_id' -Value $existing.KeyId + Write-StatusLine -Key 'fingerprint' -Value $existing.Fingerprint + Write-StatusLine -Key 'gpg' -Value $gpg + exit 0 +} + +$created = New-BatchKey -GpgPath $gpg -RealName $Name -EmailAddress $Email -Kind 'ed25519' +if (-not $created) { + $created = New-BatchKey -GpgPath $gpg -RealName $Name -EmailAddress $Email -Kind 'rsa4096' +} +if (-not $created) { + Write-StatusLine -Key 'status' -Value 'failed' + Write-StatusLine -Key 'reason' -Value 'gpg_generate_failed' + exit 1 +} + +$createdKey = Get-SecretKeyForEmail -GpgPath $gpg -EmailAddress $Email +if ($null -eq $createdKey) { + Write-StatusLine -Key 'status' -Value 'failed' + Write-StatusLine -Key 'reason' -Value 'gpg_key_not_found_after_generate' + exit 1 +} + +Write-StatusLine -Key 'status' -Value 'created' +Write-StatusLine -Key 'key_id' -Value $createdKey.KeyId +Write-StatusLine -Key 'fingerprint' -Value $createdKey.Fingerprint +Write-StatusLine -Key 'gpg' -Value $gpg diff --git a/.cursor/skills/git-init/scripts/ensure-ssh.ps1 b/.cursor/skills/git-init/scripts/ensure-ssh.ps1 new file mode 100644 index 0000000..b1aab51 --- /dev/null +++ b/.cursor/skills/git-init/scripts/ensure-ssh.ps1 @@ -0,0 +1,114 @@ +# Detect or generate ED25519 SSH key. Print public key only. Never print the private key. + +param( + [string]$Email, + [string]$KeyPath, + [switch]$ForceGenerate, + [switch]$NoGenerate, + [switch]$Quiet +) + +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'common.ps1') + +$gitProfile = Get-GitSkillsProfile + +if ([string]::IsNullOrWhiteSpace($Email)) { + if ($gitProfile) { $Email = [string]$gitProfile.userEmail } +} +if ([string]::IsNullOrWhiteSpace($Email)) { + $Email = [string](Get-GitSkillsDefaults).userEmail +} + +if ([string]::IsNullOrWhiteSpace($KeyPath)) { + if ($gitProfile -and $gitProfile.sshKeyPath) { + $KeyPath = [string]$gitProfile.sshKeyPath + } else { + $KeyPath = Join-Path $env:USERPROFILE '.ssh\id_ed25519' + } +} + +if (Test-Path -LiteralPath $KeyPath -PathType Container) { + $KeyPath = Join-Path $KeyPath 'id_ed25519' +} + +$pubPath = $KeyPath + '.pub' +$sshDir = Split-Path -Parent $KeyPath +if (-not (Test-Path -LiteralPath $sshDir)) { + New-Item -ItemType Directory -Path $sshDir -Force | Out-Null +} + +$status = 'exists' +if ($NoGenerate -and -not (Test-Path -LiteralPath $KeyPath)) { + Write-StatusLine -Key 'status' -Value 'failed' + Write-StatusLine -Key 'reason' -Value 'ssh_key_missing' + Write-StatusLine -Key 'key_path' -Value $KeyPath + exit 1 +} + +if ($ForceGenerate -or -not (Test-Path -LiteralPath $KeyPath)) { + if ((Test-Path -LiteralPath $KeyPath) -and $ForceGenerate) { + Write-StatusLine -Key 'status' -Value 'failed' + Write-StatusLine -Key 'reason' -Value 'refusing_to_overwrite_ssh_key' + Write-StatusLine -Key 'key_path' -Value $KeyPath + exit 1 + } + + $sshKeygen = Get-Command ssh-keygen -ErrorAction SilentlyContinue + if ($null -eq $sshKeygen) { + Write-StatusLine -Key 'status' -Value 'failed' + Write-StatusLine -Key 'reason' -Value 'ssh_keygen_missing' + exit 1 + } + + Invoke-GitSkillsNative -FilePath ssh-keygen -ArgumentList @('-t', 'ed25519', '-C', $Email, '-f', $KeyPath, '-N', '""') | Out-Null + if ($LASTEXITCODE -ne 0) { + Write-StatusLine -Key 'status' -Value 'failed' + Write-StatusLine -Key 'reason' -Value 'ssh_keygen_failed' + exit 1 + } + $status = 'created' +} + +if (-not (Test-Path -LiteralPath $pubPath)) { + Write-StatusLine -Key 'status' -Value 'failed' + Write-StatusLine -Key 'reason' -Value 'public_key_missing' + Write-StatusLine -Key 'key_path' -Value $KeyPath + exit 1 +} + +$agent = 'unavailable' +$added = 'skipped' +$svc = Get-Service -Name ssh-agent -ErrorAction SilentlyContinue +if ($null -ne $svc) { + try { + if ($svc.StartType -eq 'Disabled') { + Set-Service -Name ssh-agent -StartupType Manual + } + if ((Get-Service -Name ssh-agent).Status -ne 'Running') { + Start-Service -Name ssh-agent + } + $agent = 'running' + Invoke-GitSkillsNative -FilePath ssh-add -ArgumentList @($KeyPath) | Out-Null + if ($LASTEXITCODE -eq 0) { + $added = 'yes' + } else { + $added = 'failed' + } + } + catch { + $agent = 'error' + $added = 'failed' + } +} + +$publicKey = (Get-Content -LiteralPath $pubPath -Raw -Encoding UTF8).Trim() + +Write-StatusLine -Key 'status' -Value $status +Write-StatusLine -Key 'key_path' -Value $KeyPath +Write-StatusLine -Key 'public_key_path' -Value $pubPath +Write-StatusLine -Key 'ssh_agent' -Value $agent +Write-StatusLine -Key 'ssh_add' -Value $added +if (-not $Quiet) { + Write-Output ('public_key=' + $publicKey) +} diff --git a/.cursor/skills/git-init/scripts/inject-credential.ps1 b/.cursor/skills/git-init/scripts/inject-credential.ps1 new file mode 100644 index 0000000..355bbcc --- /dev/null +++ b/.cursor/skills/git-init/scripts/inject-credential.ps1 @@ -0,0 +1,112 @@ +# Agent-safe: decrypt the local token and feed it to Git Credential Manager. +# Never prints the token. Never writes it to a file. + +param( + [Parameter(Mandatory = $true)] + [string]$RemoteUrl +) + +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'common.ps1') + +$gitProfile = Get-GitSkillsProfile +if ($null -eq $gitProfile) { + Write-StatusLine -Key 'status' -Value 'failed' + Write-StatusLine -Key 'reason' -Value 'profile_missing' + exit 1 +} + +$username = ([string]$gitProfile.gitUsername).Trim() +if ([string]::IsNullOrWhiteSpace($username)) { + Write-StatusLine -Key 'status' -Value 'failed' + Write-StatusLine -Key 'reason' -Value 'git_username_missing' + exit 1 +} +if ($username -match "[\r\n]") { + Write-StatusLine -Key 'status' -Value 'failed' + Write-StatusLine -Key 'reason' -Value 'git_username_invalid' + exit 1 +} + +if (-not (Test-Path -LiteralPath $script:TokenPath)) { + Write-StatusLine -Key 'status' -Value 'failed' + Write-StatusLine -Key 'reason' -Value 'token_missing' + exit 1 +} + +$parsed = Parse-GitRemoteUrl -RemoteUrl $RemoteUrl +$hostName = [string]$parsed.CredentialHost +if ([string]::IsNullOrWhiteSpace($hostName)) { + $hostName = [string]$parsed.Host +} +if ([string]::IsNullOrWhiteSpace($hostName)) { + Write-StatusLine -Key 'status' -Value 'failed' + Write-StatusLine -Key 'reason' -Value 'host_parse_failed' + exit 1 +} + +$credProtocol = 'https' +if ([string]$parsed.Protocol -eq 'http') { + $credProtocol = 'http' +} + +$token = $null +$payload = $null +try { + $data = [System.IO.File]::ReadAllBytes($script:TokenPath) + $token = Unprotect-SecretBytes -Data $data + if ([string]::IsNullOrWhiteSpace($token)) { + Write-StatusLine -Key 'status' -Value 'failed' + Write-StatusLine -Key 'reason' -Value 'token_invalid' + exit 1 + } + + $payload = @" +protocol=$credProtocol +host=$hostName +username=$username +password=$token + +"@ + + $psi = New-Object System.Diagnostics.ProcessStartInfo + $psi.FileName = 'git' + $psi.Arguments = 'credential approve' + $psi.UseShellExecute = $false + $psi.RedirectStandardInput = $true + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.CreateNoWindow = $true + + $proc = New-Object System.Diagnostics.Process + $proc.StartInfo = $psi + [void]$proc.Start() + foreach ($line in ($payload -split "`r?`n")) { + $proc.StandardInput.WriteLine($line) + } + $proc.StandardInput.Close() + $null = $proc.StandardOutput.ReadToEnd() + $null = $proc.StandardError.ReadToEnd() + $proc.WaitForExit() + if ($proc.ExitCode -ne 0) { + Write-StatusLine -Key 'status' -Value 'failed' + Write-StatusLine -Key 'reason' -Value 'git_credential_approve_failed' + exit 1 + } +} +catch { + Write-StatusLine -Key 'status' -Value 'failed' + Write-StatusLine -Key 'reason' -Value 'inject_error' + exit 1 +} +finally { + $token = $null + $payload = $null + $data = $null +} + +Write-StatusLine -Key 'status' -Value 'injected' +Write-StatusLine -Key 'host' -Value $hostName +Write-StatusLine -Key 'protocol' -Value $credProtocol +Write-StatusLine -Key 'username' -Value $username +Write-Output 'token=hidden' diff --git a/.cursor/skills/git-init/scripts/prepare-auth.ps1 b/.cursor/skills/git-init/scripts/prepare-auth.ps1 new file mode 100644 index 0000000..fa92b7b --- /dev/null +++ b/.cursor/skills/git-init/scripts/prepare-auth.ps1 @@ -0,0 +1,74 @@ +# Agent-safe: refresh HTTPS/SSH auth for a remote URL. Never prints the token. +# Does not require being inside a git work tree (used by clone). + +param( + [Parameter(Mandatory = $true)] + [string]$RemoteUrl, + [string]$RemoteName = '' +) + +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'common.ps1') + +if ($RemoteUrl -match '@.+@' -or $RemoteUrl -match '://[^/:]+:[^@/]+@') { + $safeUrl = '' +} else { + $safeUrl = $RemoteUrl.Trim() +} + +$parsed = Parse-GitRemoteUrl -RemoteUrl $RemoteUrl +$protocol = [string]$parsed.Protocol +$hostName = [string]$parsed.Host + +$helper = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('config', '--global', '--get', 'credential.helper') | Out-String).Trim() +if ([string]::IsNullOrWhiteSpace($helper)) { + Invoke-GitSkillsNative -FilePath git -ArgumentList @('config', '--global', 'credential.helper', 'manager') | Out-Null + $helper = 'manager' +} + +$tokenPresent = Test-Path -LiteralPath $script:TokenPath +$inject = 'skipped' +if ($tokenPresent) { + & (Join-Path $PSScriptRoot 'inject-credential.ps1') -RemoteUrl $RemoteUrl + if ($LASTEXITCODE -ne 0) { + Write-StatusLine -Key 'status' -Value 'failed' + Write-StatusLine -Key 'reason' -Value 'inject_failed' + exit 1 + } + $inject = 'ok' +} +elseif ($protocol -eq 'http' -or $protocol -eq 'https') { + Write-StatusLine -Key 'status' -Value 'failed' + Write-StatusLine -Key 'reason' -Value 'token_missing' + exit 1 +} + +$ssh = 'skipped' +if ($protocol -eq 'ssh') { + $email = '' + $gitProfile = Get-GitSkillsProfile + if ($gitProfile) { $email = [string]$gitProfile.userEmail } + & (Join-Path $PSScriptRoot 'ensure-ssh.ps1') -Email $email -NoGenerate -Quiet + if ($LASTEXITCODE -ne 0) { + Write-StatusLine -Key 'status' -Value 'failed' + Write-StatusLine -Key 'reason' -Value 'ssh_failed' + exit 1 + } + $ssh = 'ok' +} + +Write-StatusLine -Key 'status' -Value 'ready' +if (-not [string]::IsNullOrWhiteSpace($RemoteName)) { + Write-StatusLine -Key 'remote' -Value $RemoteName +} +Write-StatusLine -Key 'url' -Value $safeUrl +Write-StatusLine -Key 'protocol' -Value $protocol +Write-StatusLine -Key 'host' -Value $hostName +if ($null -ne $parsed.Port) { + Write-StatusLine -Key 'port' -Value "$($parsed.Port)" +} +Write-StatusLine -Key 'credential_host' -Value ([string]$parsed.CredentialHost) +Write-StatusLine -Key 'credential_helper' -Value $helper +Write-StatusLine -Key 'inject' -Value $inject +Write-StatusLine -Key 'ssh' -Value $ssh +Write-Output 'token=hidden' diff --git a/.cursor/skills/git-init/scripts/prepare-commit.ps1 b/.cursor/skills/git-init/scripts/prepare-commit.ps1 new file mode 100644 index 0000000..f79aa8b --- /dev/null +++ b/.cursor/skills/git-init/scripts/prepare-commit.ps1 @@ -0,0 +1,57 @@ +# Agent-safe: apply local author + signing from profile. Does not commit. + +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'common.ps1') + +$inside = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('rev-parse', '--is-inside-work-tree') | Out-String).Trim() +if ($LASTEXITCODE -ne 0 -or $inside -ne 'true') { + Write-StatusLine -Key 'status' -Value 'failed' + Write-StatusLine -Key 'reason' -Value 'not_a_git_repo' + exit 1 +} + +$gitProfile = Get-GitSkillsProfile +if ($null -eq $gitProfile) { + Write-StatusLine -Key 'status' -Value 'failed' + Write-StatusLine -Key 'reason' -Value 'profile_missing' + exit 1 +} + +$userName = ([string]$gitProfile.userName).Trim() +$userEmail = ([string]$gitProfile.userEmail).Trim() +if ([string]::IsNullOrWhiteSpace($userName) -or [string]::IsNullOrWhiteSpace($userEmail)) { + Write-StatusLine -Key 'status' -Value 'failed' + Write-StatusLine -Key 'reason' -Value 'profile_incomplete' + exit 1 +} + +& git config user.name $userName +& git config user.email $userEmail +& git config commit.gpgsign true +# This skill defaults to OpenPGP signing for Gitea verification (not gpg.format=ssh). +$fmt = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('config', '--get', 'gpg.format') | Out-String).Trim() +if ($fmt -eq 'ssh') { + Invoke-GitSkillsNative -FilePath git -ArgumentList @('config', '--unset', 'gpg.format') | Out-Null +} + +$signingKey = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('config', '--get', 'user.signingkey') | Out-String).Trim() +$gpgSign = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('config', '--get', 'commit.gpgsign') | Out-String).Trim() + +$signing = 'missing' +if (-not [string]::IsNullOrWhiteSpace($signingKey)) { + $signing = 'present' +} + +Write-StatusLine -Key 'status' -Value 'ready' +Write-StatusLine -Key 'userName' -Value $userName +Write-StatusLine -Key 'userEmail' -Value $userEmail +Write-StatusLine -Key 'signing' -Value $signing +Write-StatusLine -Key 'signingkey' -Value $signingKey +Write-StatusLine -Key 'gpgsign' -Value $gpgSign + +$trailers = Get-CoAuthorTrailerLines -GitProfile $gitProfile +Write-StatusLine -Key 'co_authored_by_count' -Value "$($trailers.Count)" +foreach ($line in $trailers) { + Write-StatusLine -Key 'co_authored_by' -Value $line +} +exit 0 diff --git a/.cursor/skills/git-init/scripts/prepare-push.ps1 b/.cursor/skills/git-init/scripts/prepare-push.ps1 new file mode 100644 index 0000000..3369159 --- /dev/null +++ b/.cursor/skills/git-init/scripts/prepare-push.ps1 @@ -0,0 +1,26 @@ +# Agent-safe: refresh auth for an existing remote (default origin). + +param( + [string]$Remote = 'origin' +) + +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'common.ps1') + +$inside = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('rev-parse', '--is-inside-work-tree') | Out-String).Trim() +if ($LASTEXITCODE -ne 0 -or $inside -ne 'true') { + Write-StatusLine -Key 'status' -Value 'failed' + Write-StatusLine -Key 'reason' -Value 'not_a_git_repo' + exit 1 +} + +$remoteUrl = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('remote', 'get-url', $Remote) | Out-String).Trim() +if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($remoteUrl)) { + Write-StatusLine -Key 'status' -Value 'failed' + Write-StatusLine -Key 'reason' -Value 'remote_missing' + Write-StatusLine -Key 'remote' -Value $Remote + exit 1 +} + +& (Join-Path $PSScriptRoot 'prepare-auth.ps1') -RemoteUrl $remoteUrl -RemoteName $Remote +exit $LASTEXITCODE diff --git a/.cursor/skills/git-init/scripts/profile-status.ps1 b/.cursor/skills/git-init/scripts/profile-status.ps1 new file mode 100644 index 0000000..0cbec52 --- /dev/null +++ b/.cursor/skills/git-init/scripts/profile-status.ps1 @@ -0,0 +1,23 @@ +# Agent-safe: print local author profile fields. Never touches the token. + +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'common.ps1') + +$gitProfile = Get-GitSkillsProfile +if ($null -eq $gitProfile) { + Write-StatusLine -Key 'profile' -Value 'missing' + exit 0 +} + +Write-StatusLine -Key 'profile' -Value 'present' +Write-StatusLine -Key 'path' -Value $script:ProfilePath +Write-StatusLine -Key 'userName' -Value ([string]$gitProfile.userName) +Write-StatusLine -Key 'userEmail' -Value ([string]$gitProfile.userEmail) +Write-StatusLine -Key 'gitUsername' -Value ([string]$gitProfile.gitUsername) +Write-StatusLine -Key 'sshKeyPath' -Value ([string]$gitProfile.sshKeyPath) + +$trailers = Get-CoAuthorTrailerLines -GitProfile $gitProfile +Write-StatusLine -Key 'co_authored_by_count' -Value "$($trailers.Count)" +foreach ($line in $trailers) { + Write-StatusLine -Key 'co_authored_by' -Value $line +} diff --git a/.cursor/skills/git-init/scripts/repo-status.ps1 b/.cursor/skills/git-init/scripts/repo-status.ps1 new file mode 100644 index 0000000..f4df467 --- /dev/null +++ b/.cursor/skills/git-init/scripts/repo-status.ps1 @@ -0,0 +1,52 @@ +# Agent-safe repo snapshot. Never prints tokens or private keys. + +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'common.ps1') + +$inside = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('rev-parse', '--is-inside-work-tree') | Out-String).Trim() +if ($LASTEXITCODE -ne 0 -or $inside -ne 'true') { + Write-StatusLine -Key 'inside' -Value 'false' + exit 0 +} + +$branch = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('branch', '--show-current') | Out-String).Trim() +$head = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('rev-parse', '--short', 'HEAD') | Out-String).Trim() +$upstream = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}') | Out-String).Trim() +if ($LASTEXITCODE -ne 0) { $upstream = '' } + +$porcelain = @(Invoke-GitSkillsNative -FilePath git -ArgumentList @('status', '--porcelain')) +$dirty = if ($porcelain.Count -gt 0) { 'yes' } else { 'no' } + +$remoteUrl = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('remote', 'get-url', 'origin') | Out-String).Trim() +if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($remoteUrl)) { + $remoteUrl = '' + $safeUrl = '' + $protocol = '' +} else { + if ($remoteUrl -match '@.+@' -or $remoteUrl -match '://[^/:]+:[^@/]+@') { + $safeUrl = '' + } else { + $safeUrl = $remoteUrl + } + $parsed = Parse-GitRemoteUrl -RemoteUrl $remoteUrl + $protocol = [string]$parsed.Protocol +} + +$userName = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('config', '--get', 'user.name') | Out-String).Trim() +$userEmail = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('config', '--get', 'user.email') | Out-String).Trim() +$gpgSign = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('config', '--get', 'commit.gpgsign') | Out-String).Trim() +$signingKey = (Invoke-GitSkillsNative -FilePath git -ArgumentList @('config', '--get', 'user.signingkey') | Out-String).Trim() + +Write-StatusLine -Key 'inside' -Value 'true' +Write-StatusLine -Key 'branch' -Value $branch +Write-StatusLine -Key 'head' -Value $head +Write-StatusLine -Key 'upstream' -Value $upstream +Write-StatusLine -Key 'dirty' -Value $dirty +Write-StatusLine -Key 'origin_url' -Value $safeUrl +Write-StatusLine -Key 'protocol' -Value $protocol +Write-StatusLine -Key 'userName' -Value $userName +Write-StatusLine -Key 'userEmail' -Value $userEmail +Write-StatusLine -Key 'gpgsign' -Value $gpgSign +Write-StatusLine -Key 'signingkey' -Value $signingKey +Write-Output 'token=hidden' +exit 0 diff --git a/.cursor/skills/git-init/scripts/store-profile.ps1 b/.cursor/skills/git-init/scripts/store-profile.ps1 new file mode 100644 index 0000000..85ef494 --- /dev/null +++ b/.cursor/skills/git-init/scripts/store-profile.ps1 @@ -0,0 +1,106 @@ +# Interactive: write local author profile (not a secret). +# Run in the user's own terminal, not in an agent session. + +param( + [string]$UserName, + [string]$UserEmail, + [string]$GitUsername, + [string]$SshKeyPath +) + +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'common.ps1') + +$defaults = Get-GitSkillsDefaults + +function Read-DefaultPrompt { + param( + [string]$Label, + [string]$Default + ) + if ([string]::IsNullOrWhiteSpace($Default)) { + $value = Read-Host $Label + return $value.Trim() + } + $value = Read-Host "$Label [$Default]" + if ([string]::IsNullOrWhiteSpace($value)) { + return $Default + } + return $value.Trim() +} + +$existing = Get-GitSkillsProfile +# Do not assign to $profile - that is a PowerShell automatic variable. + +if (-not $PSBoundParameters.ContainsKey('UserName') -or [string]::IsNullOrWhiteSpace($UserName)) { + $fallback = if ($existing) { $existing.userName } else { $defaults.userName } + $UserName = Read-DefaultPrompt -Label 'Git user.name' -Default $fallback +} +if (-not $PSBoundParameters.ContainsKey('UserEmail') -or [string]::IsNullOrWhiteSpace($UserEmail)) { + $fallback = if ($existing) { $existing.userEmail } else { $defaults.userEmail } + $UserEmail = Read-DefaultPrompt -Label 'Git user.email' -Default $fallback +} +if (-not $PSBoundParameters.ContainsKey('GitUsername') -or [string]::IsNullOrWhiteSpace($GitUsername)) { + $fallback = if ($existing) { $existing.gitUsername } else { '' } + $GitUsername = Read-DefaultPrompt -Label 'Gitea username (HTTPS login name, not the token)' -Default $fallback +} +if (-not $PSBoundParameters.ContainsKey('SshKeyPath') -or [string]::IsNullOrWhiteSpace($SshKeyPath)) { + $fallback = if ($existing -and $existing.sshKeyPath) { + $existing.sshKeyPath + } else { + Join-Path $env:USERPROFILE '.ssh\id_ed25519' + } + $SshKeyPath = Read-DefaultPrompt -Label 'SSH private key path' -Default $fallback +} + +$coAuthors = @() +if ($existing -and $existing.PSObject.Properties.Name -contains 'coAuthors' -and $null -ne $existing.coAuthors) { + $coAuthors = @($existing.coAuthors) +} +$extraCo = '' +if (-not [Console]::IsInputRedirected) { + $extraCo = Read-DefaultPrompt -Label 'Extra Co-authored-by as Name (empty keeps current list)' -Default '' +} +if ($extraCo -match '^\s*(.+?)\s*<([^>]+)>\s*$') { + $entry = [pscustomobject]@{ + name = $Matches[1].Trim() + email = $Matches[2].Trim() + } + $dup = $false + foreach ($c in $coAuthors) { + if (([string]$c.name) -eq $entry.name -and ([string]$c.email) -eq $entry.email) { + $dup = $true + break + } + } + if (-not $dup) { + $coAuthors += $entry + } +} + +if ([string]::IsNullOrWhiteSpace($UserName)) { throw 'user.name is required.' } +if ([string]::IsNullOrWhiteSpace($UserEmail)) { throw 'user.email is required.' } +if ([string]::IsNullOrWhiteSpace($GitUsername)) { throw 'HTTPS Git username is required.' } + +$profileObj = [pscustomobject]@{ + userName = $UserName + userEmail = $UserEmail + gitUsername = $GitUsername + sshKeyPath = $SshKeyPath + coAuthors = @($coAuthors) + updatedAtUtc = [DateTime]::UtcNow.ToString('o') +} + +Save-GitSkillsProfile -Profile $profileObj + +Write-StatusLine -Key 'status' -Value 'saved' +Write-StatusLine -Key 'path' -Value $script:ProfilePath +Write-StatusLine -Key 'userName' -Value $UserName +Write-StatusLine -Key 'userEmail' -Value $UserEmail +Write-StatusLine -Key 'gitUsername' -Value $GitUsername +Write-StatusLine -Key 'sshKeyPath' -Value $SshKeyPath +$trailers = Get-CoAuthorTrailerLines -GitProfile $profileObj +Write-StatusLine -Key 'co_authored_by_count' -Value "$($trailers.Count)" +foreach ($line in $trailers) { + Write-StatusLine -Key 'co_authored_by' -Value $line +} diff --git a/.cursor/skills/git-init/scripts/store-token.ps1 b/.cursor/skills/git-init/scripts/store-token.ps1 new file mode 100644 index 0000000..c3d3da1 --- /dev/null +++ b/.cursor/skills/git-init/scripts/store-token.ps1 @@ -0,0 +1,42 @@ +# Interactive: encrypt a Git token with Windows DPAPI (CurrentUser). +# Run in the user's own terminal. Never print the token. + +param( + [switch]$Force +) + +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'common.ps1') + +Ensure-GitSkillsHome + +if ((Test-Path -LiteralPath $script:TokenPath) -and -not $Force) { + $answer = Read-Host 'Encrypted token already exists. Overwrite? (y/N)' + if ($answer -notmatch '^[Yy]$') { + Write-StatusLine -Key 'status' -Value 'unchanged' + exit 0 + } +} + +$secure = Read-Host 'Paste Gitea access token (input hidden)' -AsSecureString +if ($secure.Length -lt 1) { + throw 'Token is empty.' +} + +$plain = $null +try { + $plain = ConvertFrom-SecureStringPlain -Secure $secure + if ([string]::IsNullOrWhiteSpace($plain)) { + throw 'Token is empty.' + } + $protected = Protect-SecretString -PlainText $plain + [System.IO.File]::WriteAllBytes($script:TokenPath, $protected) +} +finally { + $plain = $null + $protected = $null +} + +Write-StatusLine -Key 'status' -Value 'saved' +Write-StatusLine -Key 'path' -Value $script:TokenPath +Write-Output 'token=hidden' diff --git a/.cursor/skills/git-init/scripts/test-ssh.ps1 b/.cursor/skills/git-init/scripts/test-ssh.ps1 new file mode 100644 index 0000000..d6a8d96 --- /dev/null +++ b/.cursor/skills/git-init/scripts/test-ssh.ps1 @@ -0,0 +1,66 @@ +# Agent-safe: test SSH auth against a remote (Gitea-friendly). Never prints private keys. + +param( + [Parameter(Mandatory = $true)] + [string]$RemoteUrl +) + +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'common.ps1') + +$parsed = Parse-GitRemoteUrl -RemoteUrl $RemoteUrl +if ([string]$parsed.Protocol -ne 'ssh') { + Write-StatusLine -Key 'status' -Value 'skipped' + Write-StatusLine -Key 'reason' -Value 'not_ssh_remote' + Write-StatusLine -Key 'protocol' -Value ([string]$parsed.Protocol) + exit 0 +} + +$hostName = [string]$parsed.Host +$port = $parsed.Port +$sshArgs = @() +if ($null -ne $port -and [int]$port -ne 22) { + $sshArgs += @('-p', "$port") +} +$sshArgs += @('-o', 'BatchMode=yes', '-T', "git@$hostName") + +$prevEap = $ErrorActionPreference +$ErrorActionPreference = 'Continue' +try { + $output = & ssh @sshArgs 2>&1 | Out-String + $code = $LASTEXITCODE +} +finally { + $ErrorActionPreference = $prevEap +} + +$text = if ($null -eq $output) { '' } else { [string]$output } +$ok = $false +if ($text -match 'successfully authenticated' -or + $text -match 'Hi there,' -or + $text -match 'Welcome to Gitea' -or + $text -match "You've successfully authenticated") { + $ok = $true +} + +# Gitea/GitHub often exit 1 even when auth succeeded (no shell). +if ($ok) { + Write-StatusLine -Key 'status' -Value 'ok' +} else { + Write-StatusLine -Key 'status' -Value 'failed' + Write-StatusLine -Key 'exit_code' -Value "$code" +} + +Write-StatusLine -Key 'host' -Value $hostName +if ($null -ne $port) { + Write-StatusLine -Key 'port' -Value "$port" +} else { + Write-StatusLine -Key 'port' -Value '22' +} +# Safe to show SSH server greeting; strip nothing secret-bearing beyond pubkey auth result. +$oneLine = ($text -replace '\r?\n', ' ').Trim() +if ($oneLine.Length -gt 240) { $oneLine = $oneLine.Substring(0, 240) } +Write-StatusLine -Key 'message' -Value $oneLine + +if (-not $ok) { exit 1 } +exit 0 diff --git a/.cursor/skills/git-init/scripts/token-status.ps1 b/.cursor/skills/git-init/scripts/token-status.ps1 new file mode 100644 index 0000000..676e4bd --- /dev/null +++ b/.cursor/skills/git-init/scripts/token-status.ps1 @@ -0,0 +1,34 @@ +# Agent-safe: report whether an encrypted token exists. Never prints it. + +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'common.ps1') + +if (-not (Test-Path -LiteralPath $script:TokenPath)) { + Write-StatusLine -Key 'token' -Value 'missing' + exit 0 +} + +$info = Get-Item -LiteralPath $script:TokenPath +if ($info.Length -lt 1) { + Write-StatusLine -Key 'token' -Value 'invalid' + exit 0 +} + +try { + $data = [System.IO.File]::ReadAllBytes($script:TokenPath) + $plain = Unprotect-SecretBytes -Data $data + if ([string]::IsNullOrWhiteSpace($plain)) { + Write-StatusLine -Key 'token' -Value 'invalid' + exit 0 + } +} +catch { + Write-StatusLine -Key 'token' -Value 'invalid' + exit 0 +} +finally { + $plain = $null + $data = $null +} + +Write-StatusLine -Key 'token' -Value 'present' diff --git a/.cursor/skills/git-init/scripts/verify-gitea-keys.ps1 b/.cursor/skills/git-init/scripts/verify-gitea-keys.ps1 new file mode 100644 index 0000000..3216120 --- /dev/null +++ b/.cursor/skills/git-init/scripts/verify-gitea-keys.ps1 @@ -0,0 +1,109 @@ +# Orchestrate Gitea SSH + GPG key verification challenges. +# Challenge tokens come from /user/settings/keys Verify page (short-lived). +# Never store them. Never confuse with the HTTPS access token in token.dpapi. + +param( + [string]$GpgToken, + [string]$SshToken, + [string]$KeyId, + [string]$KeyPath, + [switch]$GpgOnly, + [switch]$SshOnly +) + +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'common.ps1') + +function Resolve-DefaultGpgKeyId { + $ensure = Join-Path $PSScriptRoot 'ensure-gpg.ps1' + $gitProfile = Get-GitSkillsProfile + $name = if ($gitProfile) { [string]$gitProfile.userName } else { '' } + $email = if ($gitProfile) { [string]$gitProfile.userEmail } else { '' } + $prev = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + try { + $out = & powershell -NoProfile -ExecutionPolicy Bypass -File $ensure -Name $name -Email $email 2>&1 | Out-String + } + finally { + $ErrorActionPreference = $prev + } + if ($out -match 'key_id=([0-9A-Fa-f]+)') { + return $Matches[1] + } + return $null +} + +$doGpg = -not $SshOnly +$doSsh = -not $GpgOnly +if (-not $doGpg -and -not $doSsh) { + Write-StatusLine -Key 'status' -Value 'failed' + Write-StatusLine -Key 'reason' -Value 'nothing_to_verify' + exit 1 +} + +$failed = $false + +if ($doGpg) { + if ([string]::IsNullOrWhiteSpace($GpgToken)) { + Write-StatusLine -Key 'gpg' -Value 'need_token' + Write-Output 'ask=Open Gitea Settings > SSH/GPG keys > GPG Verify, paste the page challenge token here (NOT the HTTPS access token)' + } + else { + if ([string]::IsNullOrWhiteSpace($KeyId)) { + $KeyId = Resolve-DefaultGpgKeyId + } + if ([string]::IsNullOrWhiteSpace($KeyId)) { + Write-StatusLine -Key 'gpg' -Value 'failed' + Write-StatusLine -Key 'reason' -Value 'gpg_key_id_missing' + $failed = $true + } + else { + Write-StatusLine -Key 'gpg_key_id' -Value $KeyId + & (Join-Path $PSScriptRoot 'verify-gpg-challenge.ps1') -Token $GpgToken -KeyId $KeyId + if ($LASTEXITCODE -ne 0) { + $failed = $true + } + else { + Write-Output 'gpg_next=Paste the BEGIN/END PGP SIGNATURE block back into Gitea GPG Verify, then click Verify' + } + } + } +} + +if ($doSsh) { + if ([string]::IsNullOrWhiteSpace($SshToken)) { + Write-StatusLine -Key 'ssh' -Value 'need_token' + Write-Output 'ask=Open Gitea Settings > SSH/GPG keys > SSH Verify, paste that page challenge token here (different from GPG token)' + } + else { + $sshInvoke = @{ + Token = $SshToken + } + if (-not [string]::IsNullOrWhiteSpace($KeyPath)) { + $sshInvoke['KeyPath'] = $KeyPath + } + & (Join-Path $PSScriptRoot 'verify-ssh-challenge.ps1') @sshInvoke + if ($LASTEXITCODE -ne 0) { + $failed = $true + } + else { + Write-Output 'ssh_next=Paste the BEGIN/END SSH SIGNATURE block back into Gitea SSH Verify, then click Verify' + } + } +} + +if ($failed) { + Write-StatusLine -Key 'status' -Value 'failed' + exit 1 +} + +$gpgPending = $doGpg -and [string]::IsNullOrWhiteSpace($GpgToken) +$sshPending = $doSsh -and [string]::IsNullOrWhiteSpace($SshToken) +if ($gpgPending -or $sshPending) { + Write-StatusLine -Key 'status' -Value 'waiting_for_tokens' + exit 0 +} + +Write-StatusLine -Key 'status' -Value 'signatures_ready' +Write-Output 'done=Signatures generated. After user pastes them on Gitea and clicks Verify, check new commits for verified status.' +exit 0 diff --git a/.cursor/skills/git-init/scripts/verify-gpg-challenge.ps1 b/.cursor/skills/git-init/scripts/verify-gpg-challenge.ps1 new file mode 100644 index 0000000..a93a76f --- /dev/null +++ b/.cursor/skills/git-init/scripts/verify-gpg-challenge.ps1 @@ -0,0 +1,98 @@ +# Generate a Gitea GPG key-verification signature (official UI challenge). +# Token comes from /user/settings/keys -> Verify. Never commit the token. +# Matches Gitea template: echo "TOKEN" | gpg -a --default-key KEYID --detach-sig +# (trailing newline included - unlike SSH verification). + +param( + [Parameter(Mandatory = $true)] + [string]$Token, + [Parameter(Mandatory = $true)] + [string]$KeyId, + [string]$GpgPath +) + +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'common.ps1') + +function Resolve-GpgPath { + param([string]$Preferred) + if ($Preferred -and (Test-Path -LiteralPath $Preferred)) { return $Preferred } + $cmd = Get-Command gpg -ErrorAction SilentlyContinue + if ($cmd) { return $cmd.Source } + $candidates = @( + (Join-Path $env:ProgramFiles 'Git\usr\bin\gpg.exe'), + (Join-Path ${env:ProgramFiles(x86)} 'GnuPG\bin\gpg.exe'), + (Join-Path $env:ProgramFiles 'GnuPG\bin\gpg.exe') + ) + foreach ($p in $candidates) { + if ($p -and (Test-Path -LiteralPath $p)) { return $p } + } + return $null +} + +$gpg = Resolve-GpgPath -Preferred $GpgPath +if ($null -eq $gpg) { + Write-StatusLine -Key 'status' -Value 'failed' + Write-StatusLine -Key 'reason' -Value 'gpg_missing' + exit 1 +} + +$token = $Token.Trim() +$keyId = $KeyId.Trim() +if ([string]::IsNullOrWhiteSpace($token) -or [string]::IsNullOrWhiteSpace($keyId)) { + Write-StatusLine -Key 'status' -Value 'failed' + Write-StatusLine -Key 'reason' -Value 'token_or_key_empty' + exit 1 +} + +$bash = Join-Path $env:ProgramFiles 'Git\bin\bash.exe' +$sigText = $null +# gpg prints "using ... as default secret key" to stderr; do not treat as terminating error. +$prevEap = $ErrorActionPreference +$ErrorActionPreference = 'Continue' +try { + if (Test-Path -LiteralPath $bash) { + # Unix echo "TOKEN" includes a single trailing LF - matches Gitea UI help. + $sigText = Invoke-GitSkillsNativeWithInput -FilePath $bash -ArgumentList @('-lc', "gpg -a --default-key '$keyId' --detach-sig") -InputText ($token + "`n") | Out-String + } else { + $tmp = [System.IO.Path]::GetTempFileName() + try { + # UTF-8 token + LF, no BOM + $bytes = [System.Text.Encoding]::UTF8.GetBytes($token + "`n") + [System.IO.File]::WriteAllBytes($tmp, $bytes) + $null = Invoke-GitSkillsNative -FilePath $gpg -ArgumentList @('-a', '--default-key', $keyId, '--detach-sig', $tmp) + $asc = $tmp + '.asc' + if (Test-Path -LiteralPath $asc) { + $sigText = Get-Content -LiteralPath $asc -Raw -Encoding UTF8 + Remove-Item -LiteralPath $asc -Force -ErrorAction SilentlyContinue + } + } + finally { + Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue + } + } +} +finally { + $ErrorActionPreference = $prevEap +} + +if ($sigText -notmatch 'BEGIN PGP SIGNATURE') { + Write-StatusLine -Key 'status' -Value 'failed' + Write-StatusLine -Key 'reason' -Value 'sign_failed' + Write-StatusLine -Key 'gpg' -Value $gpg + exit 1 +} + +Write-StatusLine -Key 'status' -Value 'ok' +Write-StatusLine -Key 'key_id' -Value $keyId +Write-StatusLine -Key 'gpg' -Value $gpg +Write-Output '-----BEGIN_SIGNATURE_BODY-----' +# Print only the armored block lines for easy copy +$capture = $false +foreach ($line in ($sigText -split "`r?`n")) { + if ($line -match 'BEGIN PGP SIGNATURE') { $capture = $true } + if ($capture) { Write-Output $line } + if ($line -match 'END PGP SIGNATURE') { break } +} +Write-Output '-----END_SIGNATURE_BODY-----' +Write-Output 'Paste the block between BEGIN/END PGP SIGNATURE into Gitea Verify, then click verify.' diff --git a/.cursor/skills/git-init/scripts/verify-ssh-challenge.ps1 b/.cursor/skills/git-init/scripts/verify-ssh-challenge.ps1 new file mode 100644 index 0000000..fdd98a2 --- /dev/null +++ b/.cursor/skills/git-init/scripts/verify-ssh-challenge.ps1 @@ -0,0 +1,81 @@ +# Generate a Gitea SSH key-verification signature (official UI challenge). +# Token from /user/settings/keys -> Verify. Never commit the token. +# Matches: echo -n 'TOKEN' | ssh-keygen -Y sign -n gitea -f KEY + +param( + [Parameter(Mandatory = $true)] + [string]$Token, + [string]$KeyPath +) + +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'common.ps1') + +if ([string]::IsNullOrWhiteSpace($KeyPath)) { + $gitProfile = Get-GitSkillsProfile + if ($gitProfile -and $gitProfile.sshKeyPath) { + $KeyPath = [string]$gitProfile.sshKeyPath + } else { + $KeyPath = Join-Path $env:USERPROFILE '.ssh\id_ed25519' + } +} + +$token = $Token.Trim() +if ([string]::IsNullOrWhiteSpace($token)) { + Write-StatusLine -Key 'status' -Value 'failed' + Write-StatusLine -Key 'reason' -Value 'token_empty' + exit 1 +} + +if (-not (Test-Path -LiteralPath $KeyPath)) { + $pub = $KeyPath + '.pub' + if (Test-Path -LiteralPath $pub) { + $KeyPath = $pub + } else { + Write-StatusLine -Key 'status' -Value 'failed' + Write-StatusLine -Key 'reason' -Value 'key_missing' + Write-StatusLine -Key 'key_path' -Value $KeyPath + exit 1 + } +} + +$bash = Join-Path $env:ProgramFiles 'Git\bin\bash.exe' +if (-not (Test-Path -LiteralPath $bash)) { + Write-StatusLine -Key 'status' -Value 'failed' + Write-StatusLine -Key 'reason' -Value 'git_bash_missing' + Write-Output 'hint=Install Git for Windows, or run the echo -n | ssh-keygen command from the Gitea page in Git Bash' + exit 1 +} + +# Convert Windows path to Git Bash path: C:\Users\... -> /c/Users/... +$full = [System.IO.Path]::GetFullPath($KeyPath) +$drive = $full.Substring(0, 1).ToLower() +$rest = $full.Substring(2) -replace '\\', '/' +$unixKey = "/$drive$rest" + +$prevEap = $ErrorActionPreference +$ErrorActionPreference = 'Continue' +try { + $sigText = Invoke-GitSkillsNativeWithInput -FilePath $bash -ArgumentList @('-lc', "ssh-keygen -Y sign -n gitea -f '$unixKey'") -InputText $token | Out-String +} +finally { + $ErrorActionPreference = $prevEap +} +if ($sigText -notmatch 'BEGIN SSH SIGNATURE') { + Write-StatusLine -Key 'status' -Value 'failed' + Write-StatusLine -Key 'reason' -Value 'sign_failed' + Write-StatusLine -Key 'key_path' -Value $KeyPath + exit 1 +} + +Write-StatusLine -Key 'status' -Value 'ok' +Write-StatusLine -Key 'key_path' -Value $KeyPath +Write-Output '-----BEGIN_SIGNATURE_BODY-----' +$capture = $false +foreach ($line in ($sigText -split "`r?`n")) { + if ($line -match 'BEGIN SSH SIGNATURE') { $capture = $true } + if ($capture) { Write-Output $line } + if ($line -match 'END SSH SIGNATURE') { break } +} +Write-Output '-----END_SIGNATURE_BODY-----' +Write-Output 'Paste the SSH SIGNATURE block into Gitea Verify, then click verify.' diff --git a/.cursor/skills/git-init/workflows.md b/.cursor/skills/git-init/workflows.md new file mode 100644 index 0000000..e00b50d --- /dev/null +++ b/.cursor/skills/git-init/workflows.md @@ -0,0 +1,131 @@ +# 完整 Git 工作流 + +Agent 在 SKILL.md 路由到这些路径后再读本文件。Windows PowerShell。默认远程为 **Gitea**(见 [gitea.md](gitea.md))。认证一律走 `prepare-auth.ps1` / `prepare-push.ps1`,不得在对话中索取 Token。 + +先探测: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File "/repo-status.ps1" +``` + +## Clone + +用户提供 **Gitea** URL(以及可选目录名)。不必已在仓库内。 + +示例:`https://git.example.com:3000/owner/repo.git` 或 `ssh://git@git.example.com:2222/owner/repo.git`。 + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File "/profile-status.ps1" +powershell -NoProfile -ExecutionPolicy Bypass -File "/prepare-auth.ps1" -RemoteUrl "" +git clone "" "" +``` + +进入克隆目录后跑 `prepare-commit.ps1`(只写 local 作者与签名,不提交)。 + +SSH 且 `ssh_key_missing`:停下来,让用户先走 Init 的 SSH,或改用 HTTPS URL。 + +Init 后若需验证 SSH: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File "/test-ssh.ps1" -RemoteUrl "" +``` + +不要把 URL 里的密码打印到对话。不要 `git clone --recursive` 除非用户要求。不要把示例默认写成 github.com。 + +## Fetch / Pull + +已有仓库。不要在 pull 前擅自 commit。 + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File "/prepare-push.ps1" +git fetch origin +``` + +- 只要拉取远程引用:到此为止 +- 用户要更新当前分支:`git pull --ff-only origin`(当前分支已有上游则 `git pull --ff-only`) +- `--ff-only` 失败:报告 diverged,询问 merge 还是由用户决定;不要擅自 rebase,不要 `--force` + +脏工作区时先报告 `dirty=yes`,问是否先 stash,不要覆盖未提交改动。 + +## Branch + +```powershell +git branch -vv +git status -sb +``` + +- 列出:上面即可 +- 切换:`git switch `(没有则不要乱建)。未提交改动冲突时停下 +- 新建并切换:`git switch -c `(用户给了分支名) +- 删除本地分支:仅当用户明确要求,且不是当前分支:`git branch -d `。拒绝 `-D` 除非用户明确要强制删除 +- 不要 `push --delete` 远程分支,除非用户明确要求删除远程分支 + +## Merge + +仅当用户要求合并。 + +```powershell +git status -sb +git switch +powershell -NoProfile -ExecutionPolicy Bypass -File "/prepare-push.ps1" +git fetch origin +git merge --no-ff +``` + +冲突:停下,列出冲突文件,不要 `--abort` 除非用户要求放弃。合并提交信息仍用中文 `类型: 描述`(多为 `合并: 将 xx 合入 yy`),需要签名则 `git commit -S`。 + +不要 `rebase -i`。用户明确要求 rebase 时用非交互 `git rebase `;冲突同样停下。 + +## Tag + +```powershell +git tag -l +``` + +签名标签(与提交签名同一把 GPG 钥): + +```powershell +git tag -s "" -m @" +类型: 描述 +"@ +``` + +推送标签仅当用户要求:`prepare-push.ps1` 然后 `git push origin ""`。不要 `git push --tags` 除非用户要推送全部标签。 + +## Stash + +- 暂存:`git stash push -u -m "<中文说明>"`(用户要求保存未提交改动时) +- 列出:`git stash list` +- 恢复:`git stash pop` 仅当用户要求;冲突则停下 +- 不要 `stash drop` / `stash clear` 除非明确要求 + +## Inspect(只读) + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File "/repo-status.ps1" +git status +git diff +git diff --staged +git log --show-signature -8 +git remote -v +``` + +不要因此提交或推送。不要 `git config --list` 里把 credential 明文贴进对话。 + +## 安全撤销 + +允许(用户要求撤销暂存/改动时): + +```powershell +git restore --staged -- +git restore -- +``` + +禁止除非用户写明要丢弃历史/覆盖远程: + +- `git reset --hard` +- `git checkout --` 覆盖全部 +- `git push --force` / `--force-with-lease` +- `git rebase -i`、`git filter-branch`、`git push --delete` + +`git reset --soft HEAD~1` 仅当:用户明确要求、HEAD 是当前用户刚做的提交、且尚未推送。 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6b699bf --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.git-skills/ +*.dpapi +.env +.env.* +*.pem +id_rsa +id_ed25519 +id_ecdsa +*.token +token.txt diff --git a/README.md b/README.md new file mode 100644 index 0000000..6504ea4 --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# git-skills + +Cursor 完整 Git Skill,默认对接 **Gitea**:初始化、每次中文签名提交、每次远程推送,以及克隆、拉取、分支、合并、标签、暂存。作者档案与加密 Token 在本机,不进对话、不进仓库。 + +## 文档 + +- [使用文档](docs/使用文档.md) — 安装、Gitea Token、在 Cursor 里怎么说 +- [架构与流程图](docs/架构.md) — 组件、信任边界、流程图 +- [Gitea 说明](.cursor/skills/git-init/gitea.md) — URL / Token / SSH·GPG **添加与验证**(对齐官方文档) +- 官方:[GPG/SSH Commit Signatures](https://docs.gitea.com/administration/signing/) +- [相关文档.md](相关文档.md) — 原始任务说明 + +## 快速开始 + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File .\install.ps1 +``` + +本机写入档案与 Gitea 令牌(不要在聊天里贴 Token): + +```powershell +$scripts = "$env:USERPROFILE\.cursor\skills\git-init\scripts" +powershell -NoProfile -ExecutionPolicy Bypass -File "$scripts\store-profile.ps1" +powershell -NoProfile -ExecutionPolicy Bypass -File "$scripts\store-token.ps1" +``` + +在项目里对 Cursor 说: + +```text +用 git-init 初始化,远程是 git@git.example.com:owner/repo.git +``` + +## 安全 + +- Token:`%USERPROFILE%\.git-skills\token.dpapi`(DPAPI,当前用户) +- 不 force push、不改已有 `origin`、不改全局 `user.name` / `user.email` +- 不要安装到 `~\.cursor\skills-cursor\` + +修改 Skill 后重新运行 `install.ps1`。 diff --git a/docs/使用文档.md b/docs/使用文档.md new file mode 100644 index 0000000..f5e376e --- /dev/null +++ b/docs/使用文档.md @@ -0,0 +1,222 @@ +# git-init 使用文档 + +在 Cursor 里用本 Skill 完成完整 Git 流程。默认远程为 **Gitea**(自建或公网实例):初始化、每次签名提交、每次推送,以及克隆、拉取、分支、合并、标签、暂存。令牌只存在本机并加密,不会出现在对话或仓库里。 + +配套阅读: + +- [架构.md](架构.md) — 组件、数据流、流程图 +- [Gitea 说明](../.cursor/skills/git-init/gitea.md) — URL、Token、SSH、GPG 菜单 + +## 你需要准备什么 + +| 时机 | 你提供什么 | 不要提供什么 | +| --- | --- | --- | +| 本机一次 | 作者档案 + Gitea 访问令牌(本机终端) | 不要在 Cursor 聊天里粘贴 Token | +| 初始化 | Gitea 仓库 URL;可选 SSH/GPG 偏好 | 不要把私钥或 Token 发给 Agent | +| 日常提交 / 推送 | 说「提交」「推送」或「提交并推送」 | 不必再给 Token | +| 克隆 / 拉取 | Gitea URL 或「拉取远程」 | 不必再给 Token | + +默认作者(可在档案里改):`旅行呀~` ``。 + +提交信息必须是中文:`类型: 描述`(`初始化` / `新增` / `修复` / `优化` / `文档`)。 + +**每次提交还必须带 `Co-authored-by`**(与档案作者一致,可另加合作者): + +``` +类型: 描述 + +Co-authored-by: 旅行呀~ +``` + +由 `emit-commit-message.ps1` 自动拼上。额外合作者在 `store-profile.ps1` 里按 `Name ` 追加,写入档案的 `coAuthors`。 + +## Gitea 地址示例 + +``` +https://git.example.com/owner/repo.git +https://git.example.com:3000/owner/repo.git +git@git.example.com:owner/repo.git +ssh://git@git.example.com:2222/owner/repo.git +``` + +HTTPS:用户名 = Gitea 登录名,密码 = **访问令牌**(设置 → 应用 → 生成新令牌)。 +SSH / GPG:设置 → SSH / GPG 密钥。 + +## 安装 + +在本仓库根目录: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File .\install.ps1 +``` + +效果: + +- 项目级:`.cursor/skills/git-init`(本仓库直接可用) +- 个人级:`%USERPROFILE%\.cursor\skills\git-init`(所有项目可用) + +不要安装到 `~\.cursor\skills-cursor\`。改了 Skill 源码后重新跑一次 `install.ps1`。 + +## 本机一次配置 + +在**自己的 PowerShell**里运行(不要在对话里贴 Token): + +```powershell +$scripts = "$env:USERPROFILE\.cursor\skills\git-init\scripts" + +powershell -NoProfile -ExecutionPolicy Bypass -File "$scripts\store-profile.ps1" +powershell -NoProfile -ExecutionPolicy Bypass -File "$scripts\store-token.ps1" +``` + +- `store-profile`:姓名、邮箱、**Gitea 用户名**、SSH 私钥路径;可选追加额外 `Co-authored-by`(`Name `) +- `store-token`:隐藏输入 **Gitea 访问令牌**,DPAPI 加密到 `%USERPROFILE%\.git-skills\token.dpapi` + +检查(可在 Cursor 里跑,不会打印 Token): + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File "$scripts\profile-status.ps1" +powershell -NoProfile -ExecutionPolicy Bypass -File "$scripts\token-status.ps1" +``` + +`token-status` 只会出现 `present` / `missing` / `invalid`。 + +## 在 Cursor 里怎么说 + +把下面整句发给 Agent(把 URL 换成你的 Gitea 仓库)。 + +### 初始化 + +```text +用 git-init 初始化这个仓库,远程是 git@git.example.com:owner/repo.git +``` + +或: + +```text +用 git-init 初始化,远程是 https://git.example.com:3000/owner/repo.git +``` + +会:`git init`、写本地作者、加 origin、注入凭据、SSH/GPG、补 `.gitignore`、中文签名提交、推送 `main`。 + +### 日常提交 + +```text +按 git-init 提交这些改动 +``` + +### 日常推送 + +```text +推送到 Gitea +``` + +### 提交并推送 + +```text +提交并推送 +``` + +### 克隆 + +```text +用 git-init 克隆 https://git.example.com/owner/repo.git +``` + +### 拉取 + +```text +拉取远程更新 +``` + +默认 `--ff-only`。历史分叉时会停下问你,不会强推。 + +### 分支 / 合并 / 查看 + +```text +基于当前分支新建 feature/login 并切过去 +把 feature/login 合进 main +现在 git 状态怎么样 +``` + +更多例句见 [examples.md](../.cursor/skills/git-init/examples.md)。 + +## 能力一览 + +| 路径 | 做什么 | 认证 | +| --- | --- | --- | +| Init | 建库、远程、SSH、GPG、首次提交并推送 | Gitea Token + SSH | +| Commit | 每次中文签名提交 + `Co-authored-by` | 本地 GPG | +| Push | 每次推送当前分支 | Token 或 SSH | +| Clone | 克隆 Gitea 仓库 | `prepare-auth` | +| Sync | fetch / pull --ff-only | `prepare-push` | +| Branch / Merge / Tag / Stash / Inspect | 本地协作与只读查看 | 按需联网 | + +## 安全约定 + +- Token 只在 `%USERPROFILE%\.git-skills\token.dpapi`,绑定当前 Windows 用户 +- Agent 不得读取该文件,不得让你在聊天里贴 Token +- 不修改已有 `origin`,不改全局 `user.name` / `user.email` +- 不 `--force`、不 `reset --hard`、不跳过 hooks,除非你写明要求 + +## Gitea 密钥:添加 → 验证(必做) + +官方:[GPG/SSH Commit Signatures](https://docs.gitea.com/administration/signing/)。完整步骤见 [gitea.md](../.cursor/skills/git-init/gitea.md)。 + +网页灰色开锁 /「找不到此签名对应的密钥」= 库中没有**已验证**的可用密钥。只「增加密钥」不够,必须在 `https://<你的Gitea>/user/settings/keys` 点 **验证**。 + +### 验证 GPG(本 Skill 默认用 GPG 签提交) + +在 Cursor 里说「验证 Gitea GPG/SSH 密钥」即可。Agent 会**主动问你要页面令牌**,然后生成签名块给你贴回。 + +也可本机自己跑: + +```powershell +$scripts = "$env:USERPROFILE\.cursor\skills\git-init\scripts" +powershell -NoProfile -ExecutionPolicy Bypass -File "$scripts\verify-gitea-keys.ps1" ` + -GpgToken "GPG页面令牌" ` + -SshToken "SSH页面令牌" ` + -KeyId "你的GPG_Key_ID" +``` + +(你当前本机 Key ID 示例:`5996DA789B43D451`,以 `gpg --list-secret-keys --keyid-format LONG` 或 Gitea 页面为准。) + +说明: + +- **可以**把「验证」页上的一次性挑战令牌发给 Agent(不是访问令牌) +- **不要**把 HTTPS 访问令牌发到对话;访问令牌只用 `store-token.ps1` +- Agent 生成签名后,把 `BEGIN PGP SIGNATURE` / `BEGIN SSH SIGNATURE` 贴回 Gitea → **验证** +- 本地保持: + +```powershell +git config user.signingkey <你的GPG_Key_ID> +git config commit.gpgsign true +git config --unset gpg.format # 若曾设为 ssh +``` + +官方:GPG 用 `echo "TOKEN"`(有换行);SSH 用 `echo -n`(无换行)。验证成功后**新提交**才会显示已验证;旧提交可能仍开锁。硬刷新(Ctrl+F5)再看。 + +## 本机依赖 + +- Git for Windows(含 Git Credential Manager、建议用自带 `gpg.exe` / Git Bash) +- Windows OpenSSH(`ssh-keygen` / `ssh-agent`) +- GPG(Gpg4win,或 Git 自带 `gpg`) + +## 常见问题 + +**对话里提示 profile/token missing** +本机跑 `store-profile.ps1` / `store-token.ps1`。 + +**HTTPS 推送失败** +令牌是否过期、是否勾选仓库写权限;自建是否带端口(如 `:3000`);重新 `store-token` 后再推送。 + +**SSH Permission denied** +公钥是否已加到 Gitea;自定义端口 URL 是否写成 `ssh://git@host:2222/...`。用 `test-ssh.ps1` 测。 + +**首次连接自建主机问 fingerprint** +在本机终端确认 host key,不要把 Token 发到聊天。 + +**提交在 Gitea 网页显示「找不到此签名对应的密钥」** +对 Cursor 说「验证 Gitea SSH 和 GPG」。Agent 会主动要两个页面挑战令牌并生成签名块。HTTPS 访问令牌仍不要发到对话。详见 [gitea.md](../.cursor/skills/git-init/gitea.md)。 + +**改了 Skill 但行为没变** +再跑 `install.ps1`,必要时重开对话。 diff --git a/docs/架构.md b/docs/架构.md new file mode 100644 index 0000000..111d715 --- /dev/null +++ b/docs/架构.md @@ -0,0 +1,326 @@ +# git-init 架构 + +本文说明组件如何分工、密钥放在哪、各条 Git 路径怎么走。使用步骤见 [使用文档.md](使用文档.md)。 + +## 设计目标 + +- **对话里只出现 Gitea 仓库 URL 和 SSH/GPG 选择**,不出现 Token +- **同一套本机档案**服务于初始化以及之后每一次提交、推送、克隆、拉取 +- **默认远程为 Gitea**(自建域名与自定义端口);脚本按 URL 解析 host/port +- **Windows PowerShell** 可执行;不用 `eval "$(ssh-agent -s)"` +- **可复用**:源码在仓库 `.cursor/skills/git-init`,`install.ps1` 同步到个人 Skill 目录 + +## 逻辑架构 + +```mermaid +flowchart LR + user[UserCursorChat] + agent[AgentPlusSkill] + scripts[PowerShellScripts] + profile[ProfileJson] + tokenFile[TokenDpapi] + gcm[GitCredentialManager] + sshAgent[WindowsSshAgent] + gpg[GpgKeyring] + git[GitRepo] + remote[GiteaHost] + + user --> agent + agent --> scripts + scripts --> profile + scripts --> tokenFile + tokenFile -->|"decrypt CurrentUser"| gcm + scripts --> sshAgent + scripts --> gpg + agent --> git + gcm --> remote + sshAgent --> remote + git --> remote +``` + +三层: + +1. **Skill 指令**(`SKILL.md`):路由到 Init / Commit / Push / Clone 等,并列出安全红线 +2. **脚本**(`scripts/`):状态检查、加密存取、注入凭据、SSH/GPG;stdout 不含 Token +3. **本机秘密**(`%USERPROFILE%\.git-skills\`):与任何项目仓库隔离 + +## 目录与职责 + +```mermaid +flowchart TB + subgraph repo [git-skillsRepo] + skillMd[SKILL.md] + workflows[workflows.md] + reference[reference.md] + examples[examples.md] + scriptsDir[scripts] + installPs[install.ps1] + docsDir[docs] + end + + subgraph personal [UserHome] + personalSkill[".cursor/skills/git-init"] + secrets[".git-skills"] + end + + installPs -->|"copy skill not secrets"| personalSkill + scriptsDir --> secrets +``` + +| 路径 | 职责 | +| --- | --- | +| `.cursor/skills/git-init/SKILL.md` | Agent 入口:路由、红线、Init/Commit/Push | +| `workflows.md` | Clone / Pull / Branch / Merge / Tag / Stash / Inspect | +| `reference.md` | 中文提交规范、Windows SSH/GPG、故障表 | +| `examples.md` | 触发例句 | +| `scripts/*.ps1` | 可执行步骤;失败用退出码和 `key=value` | +| `install.ps1` | 复制 Skill 到 `~/.cursor/skills/git-init` | +| `%USERPROFILE%\.git-skills\profile.json` | 姓名、邮箱、HTTPS 用户名、SSH 路径 | +| `%USERPROFILE%\.git-skills\token.dpapi` | 仅 Token,DPAPI `CurrentUser` | + +Agent **可以**读 `profile.json`(经 `profile-status.ps1`)。Agent **不得**读 `token.dpapi`。 + +## 信任边界 + +```mermaid +flowchart TB + subgraph chat [CursorChat] + urlIn[RemoteUrl] + talk[NoTokenInChat] + end + + subgraph machine [ThisWindowsUser] + dpapi[DPAPI] + gcm[GCM] + sshKeys[SSHPrivateKey] + gpgKeys[GPGSecretKey] + end + + subgraph project [GitWorkTree] + gitDir[.git] + ignore[.gitignore] + end + + subgraph host [Gitea] + origin[origin] + end + + urlIn --> gitDir + dpapi -->|"git credential approve stdin"| gcm + gcm --> origin + sshKeys --> origin + gitDir --> origin + ignore -->|"block secrets"| gitDir +``` + +- 解密只在当前 Windows 用户下有效;换用户或换机器无法读 Token +- 注入走 stdin 给 `git credential approve`,不写进命令行参数、不写进仓库 +- 项目 `.gitignore` 忽略 `.git-skills/`、`*.dpapi`、私钥 + +## 脚本调用关系 + +```mermaid +flowchart TB + storeProfile[store-profile.ps1] + storeToken[store-token.ps1] + profileStatus[profile-status.ps1] + tokenStatus[token-status.ps1] + prepareCommit[prepare-commit.ps1] + prepareAuth[prepare-auth.ps1] + preparePush[prepare-push.ps1] + inject[inject-credential.ps1] + ensureSsh[ensure-ssh.ps1] + ensureGpg[ensure-gpg.ps1] + assertSecrets[assert-no-secrets.ps1] + repoStatus[repo-status.ps1] + common[common.ps1] + + storeProfile --> common + storeToken --> common + profileStatus --> common + tokenStatus --> common + prepareCommit --> common + prepareAuth --> inject + prepareAuth --> ensureSsh + preparePush --> prepareAuth + inject --> common + ensureSsh --> common + ensureGpg --> common + assertSecrets --> common + repoStatus --> common +``` + +| 脚本 | 谁跑 | 作用 | +| --- | --- | --- | +| `store-profile.ps1` / `store-token.ps1` | 用户本机终端 | 交互写入档案 / 加密 Token | +| `profile-status.ps1` / `token-status.ps1` | Agent | `present` / `missing`,无密钥明文 | +| `prepare-commit.ps1` | Agent | 本地作者 + `commit.gpgsign` | +| `prepare-auth.ps1` | Agent | 按 URL 注入 GCM 或 ssh-add(克隆可用) | +| `prepare-push.ps1` | Agent | 读 `origin` 再调 `prepare-auth` | +| `ensure-ssh.ps1` / `ensure-gpg.ps1` | Agent | 复用或生成密钥;日常推送 `-NoGenerate -Quiet` | +| `assert-no-secrets.ps1` | Agent | 提交前拦截密钥路径 | +| `repo-status.ps1` | Agent | 分支、脏否、打码后的 origin | + +## 主流程 + +### 总路由 + +```mermaid +flowchart TD + start[UserRequest] + start --> route{Intent} + route -->|init| initPath[Init] + route -->|commit| commitPath[Commit] + route -->|push| pushPath[Push] + route -->|commitAndPush| commitPath + commitPath -->|ifAskedPush| pushPath + route -->|clone| clonePath[Clone] + route -->|pullOrFetch| syncPath[Sync] + route -->|branchMergeTagStash| localPath[LocalGit] + route -->|statusLogDiff| inspectPath[Inspect] + route -->|commitButNotRepo| initPath +``` + +### 初始化 + +```mermaid +flowchart TD + detect[DetectRepoAndRemote] + detect --> profile{ProfileAndToken} + profile -->|missing| stopStore[StopTellUserRunStoreScripts] + profile -->|ok| askUrl[NeedRemoteUrl] + askUrl --> initGit[git init plus local author] + initGit --> addOrigin[remote add origin] + addOrigin --> inject[inject-credential] + inject --> ssh[ensure-ssh plus ssh test] + ssh --> gpg[ensure-gpg plus gpgsign] + gpg --> hygiene[gitignore and README] + hygiene --> firstCommit[Commit path] + firstCommit --> firstPush[Push main] + firstPush --> verify[status remote log signature] +``` + +### 每次提交 + +```mermaid +flowchart TD + prep[prepare-commit] + prep --> signing{signing present} + signing -->|missing| gpg[ensure-gpg] + gpg --> prep + signing -->|ok| inspect[status diff log] + inspect --> secrets[assert-no-secrets] + secrets -->|blocked| unstage[Unstage secret paths] + secrets -->|ok| stage[Stage related files] + stage --> msg[Chinese type colon description] + msg --> commit[git commit -S] + commit --> check[log show-signature] + check --> maybePush{User asked push} + maybePush -->|yes| pushPath[Push] + maybePush -->|no| done[Stop] +``` + +### 每次推送 / 克隆 / 拉取 + +```mermaid +flowchart TD + needAuth[NeedNetworkAuth] + needAuth --> hasRepo{Inside work tree} + hasRepo -->|yes| prepPush[prepare-push] + hasRepo -->|no clone| prepAuth[prepare-auth with URL] + prepPush --> proto{Protocol} + prepAuth --> proto + proto -->|https| gcm[GCM uses injected token] + proto -->|ssh| sshAdd[ssh-add Quiet] + gcm --> gitNet[git push or clone or pull] + sshAdd --> gitNet +``` + +HTTPS:Token 解密后仅进入 `git credential approve` 的 stdin。SSH:用已有 `id_ed25519`,日常路径不生成新密钥、不打印公钥。 + +## Agent 控制流 + +```mermaid +sequenceDiagram + participant U as User + participant A as Agent + participant S as Scripts + participant G as Git + participant H as GitHost + + U->>A: 提交并推送 + A->>S: profile-status token-status repo-status + S-->>A: present plus branch info + A->>S: prepare-commit + A->>S: assert-no-secrets + A->>G: git commit -S + A->>S: prepare-push + S->>G: credential approve or ssh-add + A->>G: git push + G->>H: objects + A-->>U: 作者签名与推送结果无 Token +``` + +## 与原始任务的对应 + +[相关文档.md](../相关文档.md) 中的步骤映射: + +| 文档章节 | 实现 | +| --- | --- | +| 一 init | `git init -b main` | +| 二 用户信息 | `profile.json` + 仓库 local `user.name` / `user.email` | +| 三 远程 | `git remote add origin`(已有则不改) | +| 四 Token | DPAPI + GCM,禁止写入代码 | +| 五 SSH | `ensure-ssh.ps1` + Windows `ssh-agent` | +| 六 GPG | `ensure-gpg.ps1` + `commit.gpgsign` | +| 七 / 十二 中文提交 | 每次 Commit 路径强制 `类型: 描述` | +| 八 README | 缺失才生成 | +| 九 / 十 首次提交推送 | Init 末尾走 Commit + Push | +| 十一 验收 | `status` / `remote -v` / `log --show-signature` | + +## Gitea URL 与凭据 + +```mermaid +flowchart TD + url[GiteaRemoteUrl] + url --> parse[Parse-GitRemoteUrl] + parse --> httpsPath{Protocol} + httpsPath -->|https or http| credHost[CredentialHost may include port] + httpsPath -->|ssh| sshHost[Host plus optional Port] + credHost --> inject[inject-credential to GCM] + sshHost --> sshTest[test-ssh with ssh -p] + inject --> pushHttps[git push HTTPS] + sshTest --> pushSsh[git push SSH] +``` + +| URL 示例 | CredentialHost | SSH 测试 | +| --- | --- | --- | +| `https://git.example.com/a/b.git` | `git.example.com` | 不适用 | +| `https://git.example.com:3000/a/b.git` | `git.example.com:3000` | 不适用 | +| `git@git.example.com:a/b.git` | 主机名(供日后 HTTPS) | `ssh -T git@...` | +| `ssh://git@git.example.com:2222/a/b.git` | 主机名(不含 2222) | `ssh -p 2222 -T git@...` | + +细节见 [gitea.md](../.cursor/skills/git-init/gitea.md)。 + +## Gitea 密钥验证(官方行为) + +依据 [docs.gitea.com/administration/signing](https://docs.gitea.com/administration/signing/):灰色开锁 = 数据库中找不到可校验密钥。用户密钥须在 `/user/settings/keys` **验证**后,提交签名才能被识别。 + +```mermaid +flowchart TD + addKey[AddPubkeyOnGitea] + addKey --> verifyClick[ClickVerifyOnSettings] + verifyClick --> challenge[PageShowsToken] + challenge --> localSign[verify-gitea-keys with tokens from chat] + localSign --> paste[AgentShowsSignatureBlocks] + paste --> verified[KeyMarkedVerified] + verified --> newCommit[NewSignedCommitShowsVerified] +``` + +| 脚本 | 对齐官方 UI | +| --- | --- | +| `verify-gitea-keys.ps1` | 编排:可缺令牌时提示 `need_token`;有令牌则调下面两个脚本 | +| `verify-gpg-challenge.ps1` | `echo "TOKEN" \| gpg -a --default-key KEY --detach-sig`(有换行) | +| `verify-ssh-challenge.ps1` | `echo -n 'TOKEN' \| ssh-keygen -Y sign -n gitea -f KEY`(无换行) | + +HTTPS **访问令牌**禁止进对话。页面 **验证挑战令牌**可由 Agent 主动询问(一次性,不落盘)。 diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..7e7d4a0 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,38 @@ +# Install git-init into the personal Cursor skills directory. +# Does not copy tokens, profiles, or SSH/GPG keys. + +$ErrorActionPreference = 'Stop' + +$source = Join-Path $PSScriptRoot '.cursor\skills\git-init' +$skillsRoot = Join-Path $env:USERPROFILE '.cursor\skills' +$destination = Join-Path $skillsRoot 'git-init' + +if (-not (Test-Path -LiteralPath $source)) { + throw "Skill source not found: $source" +} + +$reserved = Join-Path $env:USERPROFILE '.cursor\skills-cursor' +if ([IO.Path]::GetFullPath($destination).StartsWith([IO.Path]::GetFullPath($reserved), [StringComparison]::OrdinalIgnoreCase)) { + throw 'Refusing to install into ~/.cursor/skills-cursor (reserved).' +} + +if (-not (Test-Path -LiteralPath $skillsRoot)) { + New-Item -ItemType Directory -Path $skillsRoot -Force | Out-Null +} + +if (Test-Path -LiteralPath $destination) { + Remove-Item -LiteralPath $destination -Recurse -Force +} + +Copy-Item -LiteralPath $source -Destination $destination -Recurse -Force + +$profileScript = Join-Path $destination 'scripts\store-profile.ps1' +$tokenScript = Join-Path $destination 'scripts\store-token.ps1' + +Write-Output "status=installed" +Write-Output "path=$destination" +Write-Output 'secrets=not_copied' +Write-Output '' +Write-Output 'Next (run in your own terminal, not in chat):' +Write-Output (" powershell -NoProfile -ExecutionPolicy Bypass -File `"{0}`"" -f $profileScript) +Write-Output (" powershell -NoProfile -ExecutionPolicy Bypass -File `"{0}`"" -f $tokenScript) diff --git a/相关文档.md b/相关文档.md new file mode 100644 index 0000000..dafe78e --- /dev/null +++ b/相关文档.md @@ -0,0 +1,349 @@ +````markdown +# Git 仓库初始化配置任务 + +## 任务目标 + +你现在作为项目 Git 管理负责人,负责完成当前项目的 Git 初始化、远程仓库配置、开发者信息配置、SSH/GPG 签名配置、首次提交以及推送。 + +要求: + +- 所有 Git 操作规范化 +- 提交信息必须使用中文 +- 提交作者信息统一 +- 配置安全认证 +- 完成首次正式提交 + + +--- + +# 一、Git 初始化 + + +## 初始化仓库 + +执行: + +```bash +git init +```` + +检查状态: + +```bash +git status +``` + +--- + +# 二、配置 Git 用户信息 + +开发者信息: + +``` +姓名: +旅行呀~ + +邮箱: +travelxiao@qq.com +``` + +配置: + +```bash +git config user.name "旅行呀~" + +git config user.email "travelxiao@qq.com" +``` + +检查: + +```bash +git config --list +``` + +要求: + +提交作者显示: + +``` +旅行呀~ +``` + +--- + +# 三、添加远程仓库 + +远程仓库地址(Gitea 示例,换成你的主机): + +``` +git@git.example.com:owner/repo.git +https://git.example.com:3000/owner/repo.git +ssh://git@git.example.com:2222/owner/repo.git +``` + +添加: + +```bash +git remote add origin +``` + +检查: + +```bash +git remote -v +``` + +要求: + +``` +origin fetch +origin push +``` + +--- + +# 四、配置 Git Token + +使用 **Gitea 访问令牌** 进行 HTTPS 远程认证。 + +要求: + +* Token 仅保存本地(DPAPI 加密,见 git-init skill) +* 不允许写入代码 +* 不允许提交到仓库 + +在 Gitea:设置 → 应用 → 生成新令牌。HTTPS 用户名为 Gitea 登录名,密码为令牌。 + +配置凭据保存: + +```bash +git config --global credential.helper manager +``` + +首次由 skill 的 `store-token.ps1` / `inject-credential.ps1` 完成,不要在对话中粘贴令牌。 + +--- + +# 五、生成 SSH 密钥 + +生成 ED25519 SSH Key: + +```bash +ssh-keygen -t ed25519 -C "travelxiao@qq.com" +``` + +启动(Windows 用 OpenSSH 服务,不要用 eval ssh-agent): + +```bash +# Windows PowerShell: Start-Service ssh-agent +eval "$(ssh-agent -s)" +``` + +添加: + +```bash +ssh-add ~/.ssh/id_ed25519 +``` + +查看公钥: + +```bash +cat ~/.ssh/id_ed25519.pub +``` + +将公钥添加到 **Gitea → 设置 → SSH / GPG 密钥**。 + +测试(把主机换成你的 Gitea;自定义端口用 -p): + +```bash +ssh -T git@git.example.com +ssh -p 2222 -T git@git.example.com +``` + +或使用 skill:`test-ssh.ps1 -RemoteUrl `。 +--- + +# 六、生成 GPG 签名密钥 + +生成: + +```bash +gpg --full-generate-key +``` + +信息: + +``` +姓名: +旅行呀~ + +邮箱: +travelxiao@qq.com +``` + +查看 Key: + +```bash +gpg --list-secret-keys --keyid-format LONG +``` + +配置 Git 签名: + +```bash +git config user.signingkey KEY_ID +``` + +开启提交签名: + +```bash +git config commit.gpgsign true +``` + +将 GPG 公钥添加到 **Gitea → 设置 → SSH / GPG 密钥**(导出:`gpg --armor --export KEY_ID`)。 +--- + +# 七、Git 提交规范 + +所有提交信息必须使用中文。 + +格式: + +``` +类型: 描述 +``` + +示例: + +``` +初始化: 完成项目Git配置 + +新增: 添加用户模块 + +修复: 修复登录问题 + +优化: 优化代码结构 + +文档: 更新README +``` + +禁止: + +``` +update + +fix + +test + +修改一下 +``` + +--- + +# 八、完善 README + +完善项目根目录: + +``` +README.md +``` + +要求: + +包含: + +* 项目简介 +* 基础使用说明 +* 项目结构 +* 开发说明 + +--- + +# 九、第一次提交 + +添加文件: + +```bash +git add . +``` + +提交: + +```bash +git commit -S -m "初始化: 完成项目初始化配置并完善README文档" +``` + +--- + +# 十、推送远程仓库 + +设置主分支: + +```bash +git branch -M main +``` + +推送: + +```bash +git push -u origin main +``` + +--- + +# 十一、最终检查 + +执行: + +```bash +git status + +git remote -v + +git log --show-signature -1 +``` + +确认: + +* Git 初始化成功 +* 用户信息正确 +* 远程仓库连接成功 +* SSH 配置完成 +* GPG 签名成功 +* 首次提交完成 +* 代码成功推送 + +--- + +# 十二、后续提交要求 + +以后所有提交: + +作者: + +``` +旅行呀~ +``` + +提交信息: + +必须中文。 + +每次提交必须包含清晰描述: + +例如: + +``` +新增: 添加支付接口 + +修复: 修复数据异常问题 + +优化: 优化接口性能 +``` + +目标: + +建立规范、安全、可维护的 Git 仓库环境。 + +``` +```