初始化: 完成Git Skill测试基线

Co-authored-by: 旅行呀~ <travelxiao@qq.com>
This commit is contained in:
2026-08-20 23:42:02 +08:00
commit 2cddf06b6c
30 changed files with 3422 additions and 0 deletions
+311
View File
@@ -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 "<scripts>/<name>.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 "<scripts>/profile-status.ps1"
powershell -NoProfile -ExecutionPolicy Bypass -File "<scripts>/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 "<scripts>/ensure-gpg.ps1" -Name "<name>" -Email "<email>"
powershell -NoProfile -ExecutionPolicy Bypass -File "<scripts>/verify-gitea-keys.ps1" -GpgToken "<gpg-page-token>" -SshToken "<ssh-page-token>" -KeyId "<key_id>"
```
- 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 "<key_id>"
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 "<name>"
git config user.email "<email>"
git remote add origin "<url>"
git config --global credential.helper manager # only if unset
powershell -NoProfile -ExecutionPolicy Bypass -File "<scripts>/inject-credential.ps1" -RemoteUrl "<url>"
powershell -NoProfile -ExecutionPolicy Bypass -File "<scripts>/ensure-ssh.ps1" -Email "<email>"
powershell -NoProfile -ExecutionPolicy Bypass -File "<scripts>/test-ssh.ps1" -RemoteUrl "<url>"
powershell -NoProfile -ExecutionPolicy Bypass -File "<scripts>/ensure-gpg.ps1" -Name "<name>" -Email "<email>"
git config user.signingkey "<key_id>"
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 "<scripts>/prepare-commit.ps1"
```
If `signing=missing`:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File "<scripts>/ensure-gpg.ps1" -Name "<name>" -Email "<email>"
git config user.signingkey "<key_id>"
powershell -NoProfile -ExecutionPolicy Bypass -File "<scripts>/prepare-commit.ps1"
```
Then:
```powershell
git status
git diff
git diff --staged
git log -5 --oneline
powershell -NoProfile -ExecutionPolicy Bypass -File "<scripts>/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 "<scripts>/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: 旅行呀~ <travelxiao@qq.com>
```
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 "<scripts>/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 "<scripts>/repo-status.ps1"
powershell -NoProfile -ExecutionPolicy Bypass -File "<scripts>/prepare-auth.ps1" -RemoteUrl "<url>"
powershell -NoProfile -ExecutionPolicy Bypass -File "<scripts>/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)
+66
View File
@@ -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` → 把签名块发回让用户点「验证」。
+173
View File
@@ -0,0 +1,173 @@
# GiteaSSH / 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 "<scripts>/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 "<scripts>/test-ssh.ps1" -RemoteUrl "<SSH仓库URL>"
```
## 二、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 "<scripts>/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
```
## 四、TokenHTTPS 推送)
与密钥验证令牌不同:这是 **访问令牌**(设置 → 应用)。
- 用户名: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 "<scripts>/verify-gitea-keys.ps1" `
-GpgToken "<gpg>" -SshToken "<ssh>" -KeyId "<key_id>"
```
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` 等)
+192
View File
@@ -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: 旅行呀~ <travelxiao@qq.com>`
- 额外合作者:档案字段 `coAuthors``store-profile.ps1` 可追加 `Name <email>`
- 生成完整说明:`emit-commit-message.ps1 -Subject "类型: 描述"`
示例:
```
初始化: 完成项目Git配置
Co-authored-by: 旅行呀~ <travelxiao@qq.com>
```
```
新增: 添加用户模块
Co-authored-by: 旅行呀~ <travelxiao@qq.com>
```
禁止:
```
update
fix
test
修改一下
```
后续提交作者(`git` Author)必须为本地档案中的 `姓名 <邮箱>`,且说明里含对应 `Co-authored-by`。每次提交必须包含清晰描述,例如:
```
新增: 添加支付接口
Co-authored-by: 旅行呀~ <travelxiao@qq.com>
```
签名提交使用 `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` 解析 URLHTTPS 静默注入 TokenSSH 则 `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` 在服务被禁用时可能需要管理员权限;失败时仍展示公钥,让用户手动添加。
公钥路径:`<private-key>.pub`。只把公钥贴到 **Gitea → 设置 → SSH / GPG 密钥**。私钥不得出现在聊天或仓库里。
测试(推荐脚本,支持自建端口):
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File "<scripts>/test-ssh.ps1" -RemoteUrl "git@git.example.com:owner/repo.git"
powershell -NoProfile -ExecutionPolicy Bypass -File "<scripts>/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 <url>` 通过 `git credential approve` 交给 Git Credential Managerstdout 不含 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`
@@ -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
+291
View File
@@ -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"))
}
@@ -0,0 +1,4 @@
{
"userName": "旅行呀~",
"userEmail": "travelxiao@qq.com"
}
@@ -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")
}
@@ -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
@@ -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)
}
@@ -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'
@@ -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 = '<redacted-embedded-credentials>'
} 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'
@@ -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
@@ -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
@@ -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
}
@@ -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 = '<redacted-embedded-credentials>'
} 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
@@ -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 <email> (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
}
@@ -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'
@@ -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
@@ -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'
@@ -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
@@ -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.'
@@ -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.'
+131
View File
@@ -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 "<scripts>/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 "<scripts>/profile-status.ps1"
powershell -NoProfile -ExecutionPolicy Bypass -File "<scripts>/prepare-auth.ps1" -RemoteUrl "<url>"
git clone "<url>" "<optional-dir>"
```
进入克隆目录后跑 `prepare-commit.ps1`(只写 local 作者与签名,不提交)。
SSH 且 `ssh_key_missing`:停下来,让用户先走 Init 的 SSH,或改用 HTTPS URL。
Init 后若需验证 SSH
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File "<scripts>/test-ssh.ps1" -RemoteUrl "<url>"
```
不要把 URL 里的密码打印到对话。不要 `git clone --recursive` 除非用户要求。不要把示例默认写成 github.com。
## Fetch / Pull
已有仓库。不要在 pull 前擅自 commit。
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File "<scripts>/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 <name>`(没有则不要乱建)。未提交改动冲突时停下
- 新建并切换:`git switch -c <name>`(用户给了分支名)
- 删除本地分支:仅当用户明确要求,且不是当前分支:`git branch -d <name>`。拒绝 `-D` 除非用户明确要强制删除
- 不要 `push --delete` 远程分支,除非用户明确要求删除远程分支
## Merge
仅当用户要求合并。
```powershell
git status -sb
git switch <target>
powershell -NoProfile -ExecutionPolicy Bypass -File "<scripts>/prepare-push.ps1"
git fetch origin
git merge --no-ff <source>
```
冲突:停下,列出冲突文件,不要 `--abort` 除非用户要求放弃。合并提交信息仍用中文 `类型: 描述`(多为 `合并: 将 xx 合入 yy`),需要签名则 `git commit -S`
不要 `rebase -i`。用户明确要求 rebase 时用非交互 `git rebase <onto>`;冲突同样停下。
## Tag
```powershell
git tag -l
```
签名标签(与提交签名同一把 GPG 钥):
```powershell
git tag -s "<tag>" -m @"
:
"@
```
推送标签仅当用户要求:`prepare-push.ps1` 然后 `git push origin "<tag>"`。不要 `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 "<scripts>/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 -- <paths>
git restore -- <paths>
```
禁止除非用户写明要丢弃历史/覆盖远程:
- `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 是当前用户刚做的提交、且尚未推送。