gitpro 0.4.5__tar.gz → 0.4.6__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {gitpro-0.4.5 → gitpro-0.4.6}/PKG-INFO +1 -1
- {gitpro-0.4.5 → gitpro-0.4.6}/pyproject.toml +4 -1
- gitpro-0.4.6/skills/gitpro/SKILL.md +359 -0
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/VERSION +1 -0
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/cli.py +2 -2
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/commitcheck.py +17 -2
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/files.py +4 -2
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/guard.py +17 -0
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/mcp.py +32 -7
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/resolve.py +20 -9
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/setup.py +110 -20
- gitpro-0.4.6/src/gitpro/skillcopy.py +65 -0
- {gitpro-0.4.5 → gitpro-0.4.6}/.gitignore +0 -0
- {gitpro-0.4.5 → gitpro-0.4.6}/LICENSE +0 -0
- {gitpro-0.4.5 → gitpro-0.4.6}/README.md +0 -0
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/__init__.py +0 -0
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/__main__.py +0 -0
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/agents.py +0 -0
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/approval_policy.py +0 -0
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/args.py +0 -0
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/audit.py +0 -0
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/change_contract.py +0 -0
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/changes.py +0 -0
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/clone.py +0 -0
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/completion.py +0 -0
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/config.py +0 -0
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/deprecation.py +0 -0
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/devcontainer.py +0 -0
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/discover.py +0 -0
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/doctor.py +0 -0
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/envcmd.py +0 -0
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/example.toml +0 -0
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/forge.py +0 -0
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/health.py +0 -0
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/hot.py +0 -0
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/incident.py +0 -0
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/jsonfile.py +0 -0
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/jsonout.py +0 -0
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/keys.py +0 -0
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/learn.py +0 -0
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/lint.py +0 -0
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/lost.py +0 -0
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/operation_contract.py +0 -0
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/operation_io.py +0 -0
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/operation_reconcile.py +0 -0
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/operations.py +0 -0
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/pin.py +0 -0
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/plan.py +0 -0
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/policy.py +0 -0
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/prompt.py +0 -0
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/render.py +0 -0
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/report.py +0 -0
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/scan.py +0 -0
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/scanroots.py +0 -0
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/trailers.py +0 -0
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/tui.py +0 -0
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/undo.py +0 -0
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/util.py +0 -0
- {gitpro-0.4.5 → gitpro-0.4.6}/src/gitpro/view.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: gitpro
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.6
|
|
4
4
|
Summary: Per-remote git identities (name, email, SSH key, commit signing) from one TOML file
|
|
5
5
|
Project-URL: Homepage, https://gitpro.dev
|
|
6
6
|
Project-URL: Source, https://github.com/Yantrakar/gitpro
|
|
@@ -42,8 +42,11 @@ pattern = '\A(?P<version>\d+\.\d+\.\d+)'
|
|
|
42
42
|
[tool.hatch.build.targets.wheel]
|
|
43
43
|
packages = ["src/gitpro"]
|
|
44
44
|
|
|
45
|
+
[tool.hatch.build.targets.wheel.force-include]
|
|
46
|
+
"skills/gitpro/SKILL.md" = "gitpro/skill/SKILL.md" # the one source of the skill; setup -n compares copies to it
|
|
47
|
+
|
|
45
48
|
[tool.hatch.build.targets.sdist]
|
|
46
|
-
include = ["/src/gitpro", "/README.md", "/LICENSE"]
|
|
49
|
+
include = ["/src/gitpro", "/skills/gitpro/SKILL.md", "/README.md", "/LICENSE"]
|
|
47
50
|
|
|
48
51
|
[tool.pytest.ini_options]
|
|
49
52
|
testpaths = ["tests"]
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: gitpro
|
|
3
|
+
description: Set up and manage which git name, email, SSH key and commit signing each repo uses, chosen by the repo's remote URL, with the gitpro CLI. Use when the user wants separate work/personal git identities, a second GitHub or GitLab account, commit signing per account, asks why a commit has the wrong email, commits from a dev container, or before committing, pushing or opening a pull request for the user.
|
|
4
|
+
license: MIT
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# gitpro
|
|
8
|
+
|
|
9
|
+
`gitpro` turns one TOML file into plain git and ssh config. Git then
|
|
10
|
+
picks the identity for each repo from its remote URL. Nothing of the tool runs
|
|
11
|
+
while git runs, unless the user installed the optional guard hooks.
|
|
12
|
+
|
|
13
|
+
Docs: https://github.com/Yantrakar/gitpro/blob/main/docs/guide.md
|
|
14
|
+
|
|
15
|
+
## Rules
|
|
16
|
+
|
|
17
|
+
- **Never run `setup`, `uninstall`, `scan --fix`, `pin`, `clone` or `audit --fix`
|
|
18
|
+
without the user's explicit yes in this conversation.** Run `gitpro setup -n`
|
|
19
|
+
first, show the user what it says is still to do, and only after they
|
|
20
|
+
confirm run `gitpro setup -y`. Without a terminal, `setup` needs `-y` or `-n`
|
|
21
|
+
when there is something to change or to ask.
|
|
22
|
+
- Edit only the TOML config. The files it generates (`~/.gitconfig-identities`,
|
|
23
|
+
`~/.gitconfig-<id>`, `~/.ssh/config.d/git-identities`, the marked blocks in
|
|
24
|
+
`~/.gitconfig` and `~/.ssh/config`) are overwritten on every `setup`.
|
|
25
|
+
- Never read, print or copy private SSH keys. Public keys (`.pub`) are fine.
|
|
26
|
+
- `gitpro setup` sets up the whole machine (config, SSH keys, Git and SSH
|
|
27
|
+
files, the guard, the hook and MCP server of the agents it covers, and the
|
|
28
|
+
gitpro skill copies that already exist) after one yes. It
|
|
29
|
+
offers to look through the user's repos and proposes identities from them
|
|
30
|
+
(it never reads private keys, shell history or forge accounts). The user runs
|
|
31
|
+
it in their terminal: the first run asks questions, and new keys ask for a
|
|
32
|
+
passphrase. Don't run it yourself without their explicit yes.
|
|
33
|
+
- Don't invent owners, hosts or emails. Ask the user, or read them from the
|
|
34
|
+
remote URLs of their repos (`git remote -v`), or let `gitpro setup` find
|
|
35
|
+
them.
|
|
36
|
+
- Before any `git commit`, `git push` or `gh pr create` for the user, follow
|
|
37
|
+
"Before you commit, push or open a pull request" below.
|
|
38
|
+
- Never run `setup`, `guard install --repo`, `audit --fix`, `keys --upload` or
|
|
39
|
+
`clone` without an explicit yes. Always show the dry run first: `setup -n`,
|
|
40
|
+
`guard status`, `audit`, `clone --dry-run`. These commands ask on a
|
|
41
|
+
terminal and you have none, so pass `-y` only after the user said yes.
|
|
42
|
+
- `devcontainer.json`, `.mcp.json` and `.claude/settings.json` are team
|
|
43
|
+
files. gitpro prints what to add; edit them only when the user owns the
|
|
44
|
+
repo and asks for it.
|
|
45
|
+
- Never pass `--online`, `--history` or `--send-repo-names` unless the user
|
|
46
|
+
asks. Before an online command, tell the user what its first `online:` line
|
|
47
|
+
(or its "needs the network" message) says will be sent.
|
|
48
|
+
- When gitpro crashes (`✗ internal error (…): this is a gitpro bug`) or blocks
|
|
49
|
+
a commit or push the user thinks it should not, offer `gitpro report`
|
|
50
|
+
(`--crash`, `--denial`; `--security` for a vulnerability). Show the user
|
|
51
|
+
`gitpro report --print` and never open or file it without their yes in
|
|
52
|
+
this conversation.
|
|
53
|
+
- `gitpro env` prints the commands that fetch tokens, never the tokens. Do
|
|
54
|
+
not try to obtain token values, and never read `gh`/`glab` config files,
|
|
55
|
+
`.netrc` or `.git-credentials`.
|
|
56
|
+
- Use `--json` when you need to read a result; plain output when you show it
|
|
57
|
+
to the user. With `--json`, a writing command also needs `-y`; without it,
|
|
58
|
+
`scan --fix --json` only previews, like `-n`.
|
|
59
|
+
- A `***` in a URL gitpro prints is a password, token or secret query value
|
|
60
|
+
that it masked on purpose. The masking is for display only: do not try to
|
|
61
|
+
recover the value or paste the masked URL into a git command.
|
|
62
|
+
- Commands and MCP tools marked deprecated still work in 0.4.x and are removed
|
|
63
|
+
in 0.5.0. Use their replacements (see the end of the Commands section).
|
|
64
|
+
`GITPRO_NO_DEPRECATION=1` silences the deprecation line.
|
|
65
|
+
|
|
66
|
+
## Before you commit, push or open a pull request
|
|
67
|
+
|
|
68
|
+
1. When you may need to commit, push or open a PR, run `gitpro --version`
|
|
69
|
+
early, before the work is done.
|
|
70
|
+
2. If gitpro is missing, ask the user before installing it (`pipx install
|
|
71
|
+
gitpro`). If nobody can answer, record "blocked: gitpro missing" as a
|
|
72
|
+
blocker, keep doing the work that needs no commit, and do not write a done
|
|
73
|
+
or finish marker while a commit is blocked.
|
|
74
|
+
3. Before each commit, run `gitpro why --json` in the repo (or call the `why`
|
|
75
|
+
MCP tool) with the real `--author`, `--committer` and `--message` you will
|
|
76
|
+
use. Read the repo's `problems` and `commit.problems`. Exit code 0 alone is
|
|
77
|
+
not a pass: `why` exits 0 for a wrong committer. An error or a missing
|
|
78
|
+
field is not a pass.
|
|
79
|
+
4. `commit.would_block` is the agent hook's verdict (`[agents].hook`).
|
|
80
|
+
`commit.guard_would_block` is the Git guard's (`[guard].on_mismatch`). Stop
|
|
81
|
+
on a problem or on either being true, and show the user the `fix`.
|
|
82
|
+
5. A repo with no remote still uses gitpro's `[default]` identity.
|
|
83
|
+
6. Commit without gitpro only if all of these hold: the operator said so for
|
|
84
|
+
that repo, the repo has no remote, git's author and committer are the
|
|
85
|
+
identity the operator named, and you log it in the run log. Do not use an
|
|
86
|
+
environment variable for it.
|
|
87
|
+
7. Push and PR always fail closed. `why` checks the identity, not the push
|
|
88
|
+
target, the account or the outgoing commits. If gitpro is missing or
|
|
89
|
+
`why` fails, do not push or open the PR.
|
|
90
|
+
8. Never use `--no-verify`, `GITPRO_SKIP`, or turn off signing, unless the
|
|
91
|
+
user says so in this conversation.
|
|
92
|
+
|
|
93
|
+
Needs Linux or macOS, git 2.36+, Python 3.9+, OpenSSH. gitpro is on PyPI.
|
|
94
|
+
`gitpro --version --json` lists `capabilities`; check for one there (for
|
|
95
|
+
example `why` or `mcp`) instead of comparing version numbers.
|
|
96
|
+
|
|
97
|
+
## The config
|
|
98
|
+
|
|
99
|
+
Path: `-c PATH`, else `$GITPRO_CONFIG`, else
|
|
100
|
+
`~/.config/gitpro.toml`. `gitpro setup` finds the user's identities in their repos (or asks), writes
|
|
101
|
+
the config and can add identities while preserving an existing config. The
|
|
102
|
+
example config uses placeholder emails and hosts (`*.example`, `example.com`)
|
|
103
|
+
that gitpro refuses until they are replaced.
|
|
104
|
+
|
|
105
|
+
```toml
|
|
106
|
+
[default] # repos no identity matches (all keys optional)
|
|
107
|
+
name = "Jane Doe"
|
|
108
|
+
email = "jane@personal.dev"
|
|
109
|
+
ssh_key = "~/.ssh/id_ed25519"
|
|
110
|
+
ssh_hosts = ["github.com"] # ssh always uses ssh_key for these hosts
|
|
111
|
+
sign = true # SSH-sign commits and tags
|
|
112
|
+
|
|
113
|
+
[[identity]]
|
|
114
|
+
id = "work-github" # short name; settings go to ~/.gitconfig-<id>
|
|
115
|
+
name = "Jane Doe"
|
|
116
|
+
email = "jane.doe@employer.com"
|
|
117
|
+
host = "github.com"
|
|
118
|
+
owners = ["janedoe-employer", "employer-org"] # exact case, as in the remote URL
|
|
119
|
+
ssh_key = "~/.ssh/id_ed25519_work"
|
|
120
|
+
ssh_alias = "github-work" # ssh Host that offers only ssh_key
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
- `id`, `name`, `email`, `host` are required for an identity.
|
|
124
|
+
- `owners`: GitHub users/orgs or GitLab groups/subgroups from the URL path,
|
|
125
|
+
case-sensitive. Omit to match every repo on `host`.
|
|
126
|
+
- A repo matches when any remote matches. Git applies the includes in file
|
|
127
|
+
order, so for one remote the later identity wins. When two remotes match
|
|
128
|
+
different identities, `why` and `scan` report the repo as ambiguous until
|
|
129
|
+
the user pins one (`gitpro pin --remote R`).
|
|
130
|
+
- Two accounts on the same host need separate `ssh_key`s. Give the extra one an
|
|
131
|
+
`ssh_alias`, otherwise ssh can fall back to the default key and sign in as
|
|
132
|
+
the default account.
|
|
133
|
+
- Other identity keys: `rewrite` (default true), `rewrite_https`, `ssh_user`,
|
|
134
|
+
`ssh_port`, `sign`, `file`, `gh_account`, `glab_account`, `clone_dir`,
|
|
135
|
+
`policy`, a `trailers` table (`co_authored_by`, `forbid_emails`), and extra
|
|
136
|
+
git settings under `[default.git.<section>]` / `[identity.git.<section>]`.
|
|
137
|
+
- Optional tables: `[scan]` (`roots`, `exclude`, `stale_days`), `[guard]`
|
|
138
|
+
(`hooks`, `on_mismatch`, `chain_to`), `[clone]` (`dir`, `layout`),
|
|
139
|
+
`[forge]` (`online`, `similar`), `[agents]` (`hook`, `bot_emails`),
|
|
140
|
+
`[policy]` (`files`). The user guide's configuration section explains each.
|
|
141
|
+
- Unknown keys are errors. Read the error message and fix the TOML.
|
|
142
|
+
|
|
143
|
+
## Workflow for a change
|
|
144
|
+
|
|
145
|
+
1. Read the config. Make the smallest edit that does what the user asked, with
|
|
146
|
+
their permission to edit configuration. Or let `gitpro setup --add PATH`
|
|
147
|
+
(run by the user) learn identities from their repos.
|
|
148
|
+
2. `gitpro setup -n`. Show what it says is still to do and any `⚠` warning lines.
|
|
149
|
+
3. On the user's yes: `gitpro setup -y`.
|
|
150
|
+
4. If keys were added: `gitpro keys` prints each public key and the
|
|
151
|
+
account to register it with (twice on GitHub with `sign`: as an
|
|
152
|
+
authentication key and as a signing key). Test with `ssh -T git@<alias>`.
|
|
153
|
+
5. Verify in a repo: `gitpro why <dir>`.
|
|
154
|
+
|
|
155
|
+
## Other workflows
|
|
156
|
+
|
|
157
|
+
- **"The repo commits as the wrong person"**: `gitpro why` → show its `→`
|
|
158
|
+
fix and run it on the user's yes → `gitpro why` again until it says
|
|
159
|
+
`✓ identity matches`. If the repo has two remotes for two identities, `why`
|
|
160
|
+
and `scan` say so; the user chooses with `gitpro pin --remote R`. If history
|
|
161
|
+
is already wrong: `gitpro audit` → show the list → on the user's yes,
|
|
162
|
+
`gitpro audit --fix -y` (it rewrites only unpushed commits, needs a clean
|
|
163
|
+
working tree and prints an `↩ undo:` line; tell the user to fetch first,
|
|
164
|
+
since "pushed" means as of the last fetch).
|
|
165
|
+
- **"Set up a new machine"**: the user runs `pipx install gitpro` and
|
|
166
|
+
`gitpro setup` in their terminal (it offers to find their identities in
|
|
167
|
+
their repos) → `gitpro scan` (repos that need a fix) → `gitpro auth` (online:
|
|
168
|
+
say so first) if they want the forge checks.
|
|
169
|
+
- **"Lost work before wiping or moving a machine"**: `gitpro scan --lost`
|
|
170
|
+
(offline, read-only). Suggest a `git fetch` first so "unpushed" is current.
|
|
171
|
+
- **"Make the agent safe"**: `gitpro setup -n` shows which hooks and MCP
|
|
172
|
+
servers are in place, per agent (`agents` in `--json`). gitpro adds a hook
|
|
173
|
+
for Claude Code, Cursor and Gemini CLI, and an MCP server for those plus
|
|
174
|
+
Claude Desktop, GitHub Copilot CLI and opencode. For Codex it has
|
|
175
|
+
neither: `setup --agent codex` says so and `setup -n` exits 1. The Git
|
|
176
|
+
guard still checks its commits. Copilot CLI gets the MCP server only. "Installed"
|
|
177
|
+
means gitpro's entry is in the agent's config file, not that the agent
|
|
178
|
+
loaded it. Recommend the gitpro plugin (it brings the hook and the MCP
|
|
179
|
+
server) or `gitpro setup --agent claude`. The hook never grants anything; it
|
|
180
|
+
only denies with a reason.
|
|
181
|
+
- **"The gitpro skill is out of date"**: `gitpro setup -n` lists installed
|
|
182
|
+
copies of this skill (`~/.claude/skills/gitpro`, `~/.copilot/skills/gitpro`,
|
|
183
|
+
`~/.agents/skills/gitpro`; links to one file count once) that differ from
|
|
184
|
+
the one in this gitpro. `gitpro setup -y`, after the user's yes, updates
|
|
185
|
+
those copies with a backup. It never creates a copy.
|
|
186
|
+
- **"Clone a repo"**: `gitpro clone --dry-run <url>` → show the plan → on
|
|
187
|
+
the user's yes, `gitpro clone <url> -y`.
|
|
188
|
+
- **"Our team has rules"**: `gitpro team`, then the `gitpro check --policy
|
|
189
|
+
FILE` command it ends with (or `gitpro check --repo <dir>` for one repo)
|
|
190
|
+
for the details, and `gitpro policy show` for the rules. `gitpro why`
|
|
191
|
+
also shows a repo's policy problems. Plain `gitpro check` does not look at
|
|
192
|
+
policies.
|
|
193
|
+
- **"Set up the policy check in CI"**: GitHub, the Action in the user guide (`uses:
|
|
194
|
+
Yantrakar/gitpro@<ref>` with `fetch-depth: 0`); GitLab, the guide's
|
|
195
|
+
job. In CI, gitpro reads a policy file in the repo, and its
|
|
196
|
+
`allowed_signers`, as they are at the range base, so a pull request cannot
|
|
197
|
+
loosen the policy that judges it. A policy change in the PR takes effect
|
|
198
|
+
only once it is merged. Keep the range base on a protected branch (the
|
|
199
|
+
Action's default is), and recommend CODEOWNERS on `.gitpro-policy.toml`
|
|
200
|
+
and `allowed_signers`. These are team files: show the snippet and let the
|
|
201
|
+
user add it.
|
|
202
|
+
|
|
203
|
+
## What `why` shows
|
|
204
|
+
|
|
205
|
+
`gitpro why` prints the remotes, the identity the repo should use and why, the
|
|
206
|
+
effective name and email, the SSH key and signing, and a `→` fix for most
|
|
207
|
+
problems (a few, such as a policy that cannot be loaded, have a diagnostic and
|
|
208
|
+
no fix). The text shows one origin per setting; `why --json` has a `settings`
|
|
209
|
+
object with the `value`, `origin` and `scope` of each. `--committer EMAIL`,
|
|
210
|
+
`--author EMAIL` and `--message TEXT` with `--json` fill the `commit` object:
|
|
211
|
+
`problems`, `would_block` and `guard_would_block`. `why` exits 1 when the repo
|
|
212
|
+
itself has a problem, and 2 on an error (with `--json`, an object with `error`).
|
|
213
|
+
|
|
214
|
+
## Commands
|
|
215
|
+
|
|
216
|
+
Every command has `--help`. `gitpro --help` lists `setup`, `why`, `scan`, `pin`,
|
|
217
|
+
`clone`, `audit`, `env` and `uninstall`, then a "more" group.
|
|
218
|
+
|
|
219
|
+
The same flags mean the same everywhere: `-y` does not ask,
|
|
220
|
+
`--json` prints one JSON object, `-v` adds detail, a `PATH` names the repo or
|
|
221
|
+
folder, `-c PATH` the config. `setup -n` shows only; `setup` asks once on a terminal. `pin`,
|
|
222
|
+
`scan --fix` and `audit --fix` show a preview without `-y` (with `--json`,
|
|
223
|
+
`scan --fix` previews unless `-y` is there). A preview never grants approval.
|
|
224
|
+
|
|
225
|
+
| command | writes? | needs yes? | network |
|
|
226
|
+
|---|---|---|---|
|
|
227
|
+
| `why`, `prompt`, `completion`, `scan`, `scan --roots`, `scan --lost`, `audit`, `keys`, `keys --health`, `guard status`, `guard test`, `policy show`, `policy lint`, `team`, `env`, `setup -n`, `clone --dry-run` | no | no | no (`--online` is opt-in where present: ask first) |
|
|
228
|
+
| `report` | no | **yes**, to open the form or send; `-y` answers it | with `--online` (`gh`): say so |
|
|
229
|
+
| `guard check`, `agent-check`, `mcp` | no; git hooks, the agent hook and the MCP client call these | do not run them yourself | no |
|
|
230
|
+
| `auth`, `forge repos`, `forge forks`, `forge email` | no | no, but say it goes online | **yes**, say so |
|
|
231
|
+
| `setup` | yes: config, keys, Git and SSH files, guard, agents; previews first | **yes**, user runs it in their terminal; `-y` only after their yes | no |
|
|
232
|
+
| `scan --fix`, `pin`, `pin --remove`, `uninstall`, `guard install --repo`, `guard uninstall`, `agent uninstall`, `mcp uninstall`, `audit --fix` | yes | **yes**, then `-y` | no |
|
|
233
|
+
| `clone` | yes: a new directory | **yes**, then `-y` | yes: `git clone` |
|
|
234
|
+
| `keys --upload`, `forge similar` | adds keys to the account / sends repo names | **yes** + `--online` (+ `--send-repo-names`) | **yes** |
|
|
235
|
+
|
|
236
|
+
Deprecated in 0.4.0, removed in 0.5.0. They behave as in 0.3.4, print a line on
|
|
237
|
+
stderr and are hidden from `--help`:
|
|
238
|
+
|
|
239
|
+
| command | writes? | needs yes? | use instead |
|
|
240
|
+
|---|---|---|---|
|
|
241
|
+
| `init` | yes (deprecated): creates the example config | **yes**, then `-y` | `gitpro setup` |
|
|
242
|
+
| `apply` | yes (deprecated) | **yes**, then `-y` | `gitpro setup -y` |
|
|
243
|
+
| `unpin` | yes (deprecated) | **yes**, then `-y` | `gitpro pin --remove` |
|
|
244
|
+
| `audit --rewrite` | yes (deprecated) | **yes**, then `-y` | `gitpro audit --fix` |
|
|
245
|
+
| `guard install` | yes (deprecated without `--repo`) | **yes**, then `-y` | `gitpro setup` |
|
|
246
|
+
| `agent install`, `mcp install` | yes (deprecated) | **yes**, then `-y` | `gitpro setup --agent NAME` |
|
|
247
|
+
| `setup --write` | yes (deprecated) | **yes**, then `-y` | `gitpro setup -y` |
|
|
248
|
+
| `scan --suggest` | no (deprecated) | no | `gitpro setup --add PATH` (the user runs it) |
|
|
249
|
+
| `devcontainer --write` | yes (deprecated): **team file** | **yes**, then `-y` | VS Code "Set up devcontainer pin" |
|
|
250
|
+
| `keys --generate` | yes (deprecated): creates keys, interactive | do not run: ask the user to | `gitpro setup` |
|
|
251
|
+
| `plan`, plain `check`, `doctor`, `which`, `lost`, `discover`, `agent status`, `devcontainer`, `devcontainer status` | no (deprecated) | no | `setup -n`, `setup -n`, `setup -n` and `why`, `why --json`, `scan --lost`, `setup PATH`, `setup -n`, VS Code, VS Code |
|
|
252
|
+
| `preflight`, `operation-status`, `operation-reconcile`, `change propose`, `change status` | no (deprecated) | no | none |
|
|
253
|
+
| `run` | yes (deprecated) | **yes**, then `-y` | none |
|
|
254
|
+
| `change approve`, `change apply`, `change recover` | yes (deprecated) | **yes** | none |
|
|
255
|
+
|
|
256
|
+
Notes:
|
|
257
|
+
|
|
258
|
+
- Plain `check` only exits 1 when `apply` would change something; it never
|
|
259
|
+
looks at policies. `check --repo DIR` checks the repo's identity against its
|
|
260
|
+
team policy, `check --policy FILE` checks every identity that uses FILE,
|
|
261
|
+
and `check --policy FILE --range A..B` checks commits (CI; the base's policy
|
|
262
|
+
judges). Only plain `check` is deprecated (use `setup -n`); the policy forms
|
|
263
|
+
stay and are listed under "more".
|
|
264
|
+
- `setup -n` exits 0 when nothing is to do, 1 when something is (or when an
|
|
265
|
+
agent named with `--agent` cannot be covered), 2 on an error. `--json` has
|
|
266
|
+
`identities`, `pending`, `done`, `notes`, `agents` (per agent: `hook` and
|
|
267
|
+
`mcp`, each `installed`, `plugin`, `missing` or `unavailable`), `uncovered`
|
|
268
|
+
and `skills`; `ok` is false when `pending` or `uncovered` is not empty.
|
|
269
|
+
- `team --online` shows `⚠` (never `✗`) when the forge did not answer; the
|
|
270
|
+
reason is on stderr and in `warnings`.
|
|
271
|
+
- `scan --json`, `why --json` and the other `--json` forms print one JSON
|
|
272
|
+
object for you to read. `scan --lost` adds a `lost` object. `--lost` with
|
|
273
|
+
`--fix` is refused.
|
|
274
|
+
- `scan --fix` keeps an override that is a repo's only identity. Without `-y`
|
|
275
|
+
it shows a preview (`scan --fix --json` too). With `-y` it prints the undo
|
|
276
|
+
script's path.
|
|
277
|
+
- `pin --remote R` pins the identity that remote R's URL matches, for a repo
|
|
278
|
+
whose remotes match two identities. It also writes that identity's signing
|
|
279
|
+
and key settings. `pin --remove` with `--remote` is refused.
|
|
280
|
+
- `uninstall` removes the Git and SSH files `setup` wrote and the global
|
|
281
|
+
guard hooks. It keeps the config and the keys. It does not remove agent
|
|
282
|
+
hooks, MCP registrations, per-repo hooks, pins, skill copies or
|
|
283
|
+
`devcontainer.json` entries; each of those has its own undo.
|
|
284
|
+
- `env` output is meant for `eval "$(gitpro env)"` in the user's shell.
|
|
285
|
+
- A deprecated command with `--json` has `"deprecated": {"removed_in": "0.5.0", "use": …}`
|
|
286
|
+
in its object and prints nothing on stderr.
|
|
287
|
+
|
|
288
|
+
## MCP server
|
|
289
|
+
|
|
290
|
+
The gitpro plugin (or `gitpro setup --agent NAME`) gives you an MCP server.
|
|
291
|
+
Its tools have the commands' names and return the same JSON as the matching
|
|
292
|
+
`--json` command:
|
|
293
|
+
|
|
294
|
+
- `why`: the repo's identity. Takes `path`, `remote`, `author_email`,
|
|
295
|
+
`committer_email` and `message`. Its `settings` field is the effective
|
|
296
|
+
settings; its `commit` field has `problems`, `would_block` (the agent hook's
|
|
297
|
+
verdict) and `guard_would_block` (the Git guard's). On an error it returns
|
|
298
|
+
`isError` with the same object `why --json` prints (`error`, `config: null`,
|
|
299
|
+
and the repo fields when the path is a repo).
|
|
300
|
+
- `setup`: the state of the machine (`setup -n --json`): identities and what is
|
|
301
|
+
still to do. Changes nothing.
|
|
302
|
+
- `scan`: the repos and what needs a fix. With `lost: true` it adds the lost-work
|
|
303
|
+
checks.
|
|
304
|
+
- `audit`: commits made as the wrong identity.
|
|
305
|
+
- `env`: the commands that fetch the forge token, never a token.
|
|
306
|
+
- `report`: a redacted bug report draft.
|
|
307
|
+
|
|
308
|
+
Deprecated tools stay in the list in 0.4.x, with a description that starts
|
|
309
|
+
`Deprecated, removed in 0.5.0; use <tool>.`, and are removed in 0.5.0
|
|
310
|
+
(`apply_change`, `recover_change` and `run_operation` are listed only when the
|
|
311
|
+
server runs with `--allow-execution`, as before):
|
|
312
|
+
|
|
313
|
+
| deprecated tool | use |
|
|
314
|
+
|---|---|
|
|
315
|
+
| `which_identity` | `why` (its `settings`) |
|
|
316
|
+
| `check_commit` | `why` with `committer_email`, `author_email`, `message` (its `commit`) |
|
|
317
|
+
| `identities` | `setup` |
|
|
318
|
+
| `lost` | `scan` with `lost: true` |
|
|
319
|
+
| `env_for` | `env` |
|
|
320
|
+
| `draft_report` | `report` |
|
|
321
|
+
| `preflight`, `operation_status`, `reconcile_operation`, `propose_change`, `change_status`, `apply_change`, `recover_change`, `run_operation` | none |
|
|
322
|
+
|
|
323
|
+
- Before committing, follow "Before you commit, push or open a pull request".
|
|
324
|
+
Pass `author_email`, `committer_email` and `message`.
|
|
325
|
+
- Every default tool is read-only. Tools listed only with `--allow-execution`
|
|
326
|
+
are not.
|
|
327
|
+
- A `fix` field is a command to propose to the user, not to run.
|
|
328
|
+
|
|
329
|
+
## Troubleshooting
|
|
330
|
+
|
|
331
|
+
- Not sure what is wrong: `gitpro setup -n` shows what is missing on the
|
|
332
|
+
machine, and `gitpro why` explains one repo.
|
|
333
|
+
- Wrong or no identity: compare `why` output's remote URL with `host` and
|
|
334
|
+
`owners` (case!). A repo-level `user.email` overrides everything:
|
|
335
|
+
`gitpro scan <dir>` shows it, `gitpro scan --fix -y <dir>` removes it.
|
|
336
|
+
- "Please tell me who you are": no identity matches and `[default]` has no
|
|
337
|
+
name/email. This is intended; add an identity or a default. A repo with no
|
|
338
|
+
remote uses `[default]`; `why` says "gitpro has no identity for this repo"
|
|
339
|
+
and whether git will use another email or refuse.
|
|
340
|
+
- Inside a dev container (VS Code Dev Containers copy `~/.gitconfig` but not
|
|
341
|
+
its includes): on the host, run `gitpro pin` in the repo, or use VS Code's
|
|
342
|
+
"Set up devcontainer pin" so it happens on every start. An ordinary pin
|
|
343
|
+
does not pin signing or the SSH key; `pin --remote` writes both.
|
|
344
|
+
- Push or clone signs in as the wrong account: the identity's key isn't
|
|
345
|
+
registered with that account, or it has no `ssh_alias` while the host is in
|
|
346
|
+
`[default].ssh_hosts`. `gitpro auth` (online) shows which account each
|
|
347
|
+
alias signs in as.
|
|
348
|
+
- husky, lefthook or pre-commit: repos with their own `core.hooksPath` are
|
|
349
|
+
not covered by the global guard; suggest `gitpro guard install --repo <dir>`.
|
|
350
|
+
- "A hook blocked me": read the `cause:` and `fix:` lines, show them to the
|
|
351
|
+
user and ask before running the fix.
|
|
352
|
+
- "A hook printed `exited N; not blocking`": the guard failed open and the
|
|
353
|
+
commit went through. Run `gitpro why` in the repo and `gitpro setup -n`.
|
|
354
|
+
- The agent hook does nothing: it runs `gitpro` from a plain `sh`, so gitpro
|
|
355
|
+
must be on that `PATH` (`command -v gitpro`).
|
|
356
|
+
- A new agent shows `unavailable` in `setup -n`: gitpro has no hook or MCP
|
|
357
|
+
entry for it. That is not a gitpro failure; the Git guard still applies.
|
|
358
|
+
- Something else, or a gitpro bug: `gitpro report --print` drafts a redacted
|
|
359
|
+
issue (versions and `why`); the user decides whether to open it.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
0.4.6 2026-11-06 setup covers agents and skills, why gives the guard verdict
|
|
1
2
|
0.4.5 2026-10-22 release: releasepro 0.2.2, plain short announcement
|
|
2
3
|
0.4.4 2026-10-22 release: run by releasepro, notes from the changelog
|
|
3
4
|
0.4.3 2026-10-21 release: gitpro.dev last, MCP Registry after PyPI only
|
|
@@ -494,8 +494,8 @@ def cmd_scan(args) -> int:
|
|
|
494
494
|
if args.lost and args.fix:
|
|
495
495
|
raise config.ConfigError("--lost lists work only you can sort out, so it does not go with --fix; "
|
|
496
496
|
"run gitpro scan --lost without --fix")
|
|
497
|
-
# -n shows the fix and changes nothing; --json without -y
|
|
498
|
-
preview = bool(args.fix and args.dry_run and preview_only(args))
|
|
497
|
+
# -n shows the fix and changes nothing; so does --json without -y (spec 23 section 3.1)
|
|
498
|
+
preview = bool(args.fix and ((args.dry_run and preview_only(args)) or (args.json and not args.yes)))
|
|
499
499
|
if preview:
|
|
500
500
|
args.fix = False
|
|
501
501
|
ask = args.fix and not args.yes and not args.json
|
|
@@ -100,9 +100,23 @@ def call_spec(fn: Callable, values: List[Tuple[str, object]]):
|
|
|
100
100
|
return fn(*rest[:len(positional)], **kwargs)
|
|
101
101
|
|
|
102
102
|
|
|
103
|
+
def guard_would_block(rep, cfg, committer_email: Optional[str], author_email: Optional[str],
|
|
104
|
+
probs: List[dict]) -> bool:
|
|
105
|
+
"""Would the Git guard refuse this commit? The identity check is guard.py's own (guard.commit_verdict).
|
|
106
|
+
A trailer problem counts because the commit-msg hook refuses it. Both follow [guard] on_mismatch, not
|
|
107
|
+
[agents].hook. Team-policy hooks are not evaluated."""
|
|
108
|
+
from . import guard
|
|
109
|
+
committer = committer_email or rep.effective.email.value or None
|
|
110
|
+
verdict = guard.commit_verdict(rep, cfg, committer, author_email or committer)
|
|
111
|
+
trailer = any(p["code"] == "trailer" for p in probs) and cfg.guard.on_mismatch == "block"
|
|
112
|
+
return bool(verdict["would_block"]) or trailer
|
|
113
|
+
|
|
114
|
+
|
|
103
115
|
def judge(rep, cfg, *, committer_email: Optional[str] = None, author_email: Optional[str] = None,
|
|
104
116
|
message: Optional[str] = None) -> dict:
|
|
105
|
-
"""{would_block, mode, problems, reason, fix[, warnings]} for a commit in rep.repo.
|
|
117
|
+
"""{would_block, mode, problems, reason, fix, guard_would_block[, warnings]} for a commit in rep.repo.
|
|
118
|
+
would_block is the agent hook's verdict ([agents].hook); guard_would_block is the Git guard's
|
|
119
|
+
([guard] on_mismatch)."""
|
|
106
120
|
from . import resolve
|
|
107
121
|
probs = identity_problems(rep, cfg, committer_email, author_email)
|
|
108
122
|
probs += [_problem(p.code, p.message, resolve._shown_fix(p) or None) for p in rep.problems
|
|
@@ -112,7 +126,8 @@ def judge(rep, cfg, *, committer_email: Optional[str] = None, author_email: Opti
|
|
|
112
126
|
probs += trailer_problems(rep.repo, rep, cfg, message, warnings)
|
|
113
127
|
mode = cfg.agents.hook
|
|
114
128
|
out = {"would_block": bool(probs) and mode == "block", "mode": mode, "problems": probs,
|
|
115
|
-
"reason": probs[0]["message"] if probs else None, "fix": probs[0]["fix"] if probs else None
|
|
129
|
+
"reason": probs[0]["message"] if probs else None, "fix": probs[0]["fix"] if probs else None,
|
|
130
|
+
"guard_would_block": guard_would_block(rep, cfg, committer_email, author_email, probs)}
|
|
116
131
|
if warnings:
|
|
117
132
|
out["warnings"] = warnings
|
|
118
133
|
return out
|
|
@@ -72,12 +72,14 @@ def show(changes: list[Change], diffs: bool) -> None:
|
|
|
72
72
|
(c.old or "").splitlines(True), (c.new or "").splitlines(True), f"a{c.path}", f"b{c.path}"))
|
|
73
73
|
|
|
74
74
|
|
|
75
|
-
def _backup(path: Path, data: bytes | None = None) -> Path:
|
|
75
|
+
def _backup(path: Path, data: bytes | None = None, into: Path | None = None) -> Path:
|
|
76
|
+
"""A copy of path (O_EXCL, 0600) named <name>.bak.<stamp>, beside it or in the folder into."""
|
|
76
77
|
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
77
78
|
if data is None:
|
|
78
79
|
data = path.read_bytes()
|
|
79
80
|
for n in range(100):
|
|
80
|
-
|
|
81
|
+
name = f"{path.name}.bak.{stamp}" + (f".{n}" if n else "")
|
|
82
|
+
bak = into / name if into is not None else path.with_name(name)
|
|
81
83
|
try:
|
|
82
84
|
fd = os.open(bak, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
83
85
|
except FileExistsError:
|
|
@@ -309,6 +309,23 @@ def identity_commit(ctx: Context) -> int:
|
|
|
309
309
|
return 0
|
|
310
310
|
|
|
311
311
|
|
|
312
|
+
def commit_verdict(rep, cfg, committer_email: Optional[str], author_email: Optional[str]) -> Dict[str, object]:
|
|
313
|
+
"""What the pre-commit identity check (identity_commit above) decides for a commit in rep.repo, without
|
|
314
|
+
printing, recording a denial or running any process. {"refuses": the check returns BLOCK,
|
|
315
|
+
"would_block": it does and [guard] on_mismatch is "block", "mode": on_mismatch}. Team-policy entries
|
|
316
|
+
and the commit-msg trailer check are not run here. GITPRO_SKIP is not read."""
|
|
317
|
+
global _RECORD
|
|
318
|
+
ctx = Context("pre-commit", rep.repo, (), "", cfg, rep, committer_email or None, author_email or None,
|
|
319
|
+
lambda line: None)
|
|
320
|
+
before, _RECORD = _RECORD, False
|
|
321
|
+
try:
|
|
322
|
+
refuses = identity_commit(ctx) == BLOCK
|
|
323
|
+
finally:
|
|
324
|
+
_RECORD = before
|
|
325
|
+
mode = cfg.guard.on_mismatch
|
|
326
|
+
return {"refuses": refuses, "would_block": refuses and mode == "block", "mode": mode}
|
|
327
|
+
|
|
328
|
+
|
|
312
329
|
def _outgoing(repo: Path, stdin: str) -> List[Tuple[str, str, str, str]]:
|
|
313
330
|
"""(committer email, author email, short sha, subject) of every commit this push sends that no
|
|
314
331
|
remote-tracking ref has, oldest last as git log prints them."""
|
|
@@ -46,7 +46,12 @@ PARSE_ERROR, INVALID_REQUEST, NO_METHOD, INVALID_PARAMS, INTERNAL_ERROR = -32700
|
|
|
46
46
|
|
|
47
47
|
|
|
48
48
|
class ToolError(Exception):
|
|
49
|
-
"""A gitpro error a tool reports as a result with isError (the server carries on).
|
|
49
|
+
"""A gitpro error a tool reports as a result with isError (the server carries on). data, when given, is
|
|
50
|
+
the same error object the command's --json prints; the result carries it instead of the message."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, message: str = "", data: Optional[dict] = None):
|
|
53
|
+
super().__init__(message)
|
|
54
|
+
self.data = data
|
|
50
55
|
|
|
51
56
|
|
|
52
57
|
class RpcError(Exception):
|
|
@@ -152,7 +157,13 @@ def tool_why(args: dict, ctx: Ctx) -> dict:
|
|
|
152
157
|
from . import resolve
|
|
153
158
|
resolve.require_git()
|
|
154
159
|
repo = _repo(args["path"])
|
|
155
|
-
|
|
160
|
+
try:
|
|
161
|
+
cfg = ctx.config()
|
|
162
|
+
except ConfigError as e:
|
|
163
|
+
if ctx.cfg_path: # an explicit config is an error, as `why -c PATH --json`
|
|
164
|
+
raise
|
|
165
|
+
# the object `why --json` prints when there is no usable config (shared construction)
|
|
166
|
+
raise ToolError(str(e), _envelope("why", {"ok": False, **resolve.why_error(e, repo)})) from None
|
|
156
167
|
rep = resolve.compare(repo, cfg, remote=args.get("remote"), home=ctx.home)
|
|
157
168
|
data = rep.to_json()
|
|
158
169
|
data.pop("ok")
|
|
@@ -666,7 +677,7 @@ class Server:
|
|
|
666
677
|
return self.failed(f"internal error: the tool stopped ({type(e).__name__}"
|
|
667
678
|
+ (f" {code}" if code not in (None, 0) else "") + ")")
|
|
668
679
|
except (ToolError, ConfigError, OSError, UnicodeError) as e:
|
|
669
|
-
return self.
|
|
680
|
+
return self.failed_with(str(e), getattr(e, "data", None))
|
|
670
681
|
except subprocess.CalledProcessError as e:
|
|
671
682
|
cmd = e.cmd if isinstance(e.cmd, str) else shlex.join(str(c) for c in e.cmd)
|
|
672
683
|
return self.failed(f"{cmd} failed ({e.returncode})")
|
|
@@ -696,6 +707,17 @@ class Server:
|
|
|
696
707
|
def failed(message: str) -> dict:
|
|
697
708
|
return {"content": [{"type": "text", "text": f"✗ {redact(message)}"}], "isError": True}
|
|
698
709
|
|
|
710
|
+
def failed_with(self, message: str, data: Optional[dict]) -> dict:
|
|
711
|
+
"""An error result. With data, the command's own error object, as `--json` prints it."""
|
|
712
|
+
if data is None:
|
|
713
|
+
return self.failed(message)
|
|
714
|
+
obj = redact(data)
|
|
715
|
+
out = {"content": [{"type": "text", "text": json.dumps(obj, ensure_ascii=False, allow_nan=False)}],
|
|
716
|
+
"isError": True}
|
|
717
|
+
if self.version >= STRUCTURED_SINCE:
|
|
718
|
+
out["structuredContent"] = obj
|
|
719
|
+
return out
|
|
720
|
+
|
|
699
721
|
|
|
700
722
|
def _error(mid: object, code: int, message: str) -> dict:
|
|
701
723
|
return {"jsonrpc": "2.0", "id": mid, "error": {"code": code, "message": message}}
|
|
@@ -755,11 +777,12 @@ def cmd_mcp(args: argparse.Namespace) -> int:
|
|
|
755
777
|
|
|
756
778
|
# ---------------------------------------------------------------- install / uninstall
|
|
757
779
|
|
|
758
|
-
AGENTS = ("claude", "claude-desktop", "cursor", "gemini", "opencode")
|
|
780
|
+
AGENTS = ("claude", "claude-desktop", "copilot", "cursor", "gemini", "opencode")
|
|
759
781
|
ENTRY = {"type": "stdio", "command": "gitpro", "args": ["mcp"]}
|
|
760
782
|
PLAIN_ENTRY = {"command": "gitpro", "args": ["mcp"]} # formats that document no "type"
|
|
761
783
|
OPENCODE_ENTRY = {"type": "local", "command": ["gitpro", "mcp"]}
|
|
762
|
-
|
|
784
|
+
COPILOT_ENTRY = {"type": "local", "command": "gitpro", "args": ["mcp"], "tools": ["*"]} # "*": all the tools
|
|
785
|
+
PROJECT_FILES = {"claude": ".mcp.json", "copilot": ".github/mcp.json", "cursor": ".cursor/mcp.json"}
|
|
763
786
|
CLAUDE_ADD = ["claude", "mcp", "add", "--scope", "user", "--transport", "stdio", "gitpro", "--", "gitpro", "mcp"]
|
|
764
787
|
CLAUDE_REMOVE = ["claude", "mcp", "remove", "--scope", "user", "gitpro"]
|
|
765
788
|
|
|
@@ -772,6 +795,8 @@ def _user_target(agent: str, home: Path) -> Tuple[Path, str, dict]:
|
|
|
772
795
|
if agent == "claude-desktop":
|
|
773
796
|
base = home / "Library/Application Support/Claude" if sys.platform == "darwin" else xdg / "Claude"
|
|
774
797
|
return base / "claude_desktop_config.json", "mcpServers", PLAIN_ENTRY
|
|
798
|
+
if agent == "copilot":
|
|
799
|
+
return home / ".copilot/mcp-config.json", "mcpServers", COPILOT_ENTRY
|
|
775
800
|
if agent == "cursor":
|
|
776
801
|
return home / ".cursor/mcp.json", "mcpServers", ENTRY
|
|
777
802
|
if agent == "gemini":
|
|
@@ -792,7 +817,7 @@ def _target(args: argparse.Namespace) -> Tuple[Path, str, dict]:
|
|
|
792
817
|
top = resolve.is_repo(Path.cwd())
|
|
793
818
|
if top is None:
|
|
794
819
|
raise ConfigError(f"{Path.cwd()}: not a git repository (--scope project edits the repo's {rel})")
|
|
795
|
-
return top / rel, "mcpServers", ENTRY
|
|
820
|
+
return top / rel, "mcpServers", COPILOT_ENTRY if args.agent == "copilot" else ENTRY
|
|
796
821
|
|
|
797
822
|
|
|
798
823
|
def _ours(value: object, entry: dict) -> bool:
|
|
@@ -1005,7 +1030,7 @@ def register(sub, common, confirm) -> None:
|
|
|
1005
1030
|
a = actions.add_parser(name, parents=[confirm], help=text)
|
|
1006
1031
|
a.add_argument("--agent", choices=AGENTS, default="claude", help="which assistant (default: claude)")
|
|
1007
1032
|
a.add_argument("--scope", choices=("user", "project"), default="user",
|
|
1008
|
-
help="user config (default) or the repo's tracked file (claude, cursor; needs --write)")
|
|
1033
|
+
help="user config (default) or the repo's tracked file (claude, copilot, cursor; needs --write)")
|
|
1009
1034
|
a.add_argument("--json", action="store_true", help="machine-readable output (needs -y)")
|
|
1010
1035
|
if name == "install":
|
|
1011
1036
|
a.add_argument("--force", action="store_true",
|
|
@@ -843,8 +843,11 @@ def compare(repo: Path, cfg: Config, *, remote: Optional[str] = None,
|
|
|
843
843
|
who = _who(exp)
|
|
844
844
|
|
|
845
845
|
if exp.email is None:
|
|
846
|
-
|
|
847
|
-
|
|
846
|
+
if candidates:
|
|
847
|
+
fix = f"add an identity: gitpro setup --add {_word(str(top))}"
|
|
848
|
+
else: # no remote url: no identity pattern can ever match, so only [default] applies
|
|
849
|
+
fix = f"add a name and email under [default] in {_tilde(str(cfg.path)) if cfg.path else 'the config'}"
|
|
850
|
+
out.append(_p("no_identity", f"no identity for {redact_url(first_url) or top}", None, fix))
|
|
848
851
|
|
|
849
852
|
if exp.match.identity is None:
|
|
850
853
|
for name, url in candidates:
|
|
@@ -1008,7 +1011,9 @@ def render_text(rep: Report, cfg: Config) -> str:
|
|
|
1008
1011
|
if e.email:
|
|
1009
1012
|
lines.append(f" expected {label} {e.name or '(no name)'} <{e.email}>")
|
|
1010
1013
|
else:
|
|
1011
|
-
lines.append(f" expected {label} no name/email in [default];
|
|
1014
|
+
lines.append(f" expected {label} gitpro has no identity for this repo (no name/email in [default]); "
|
|
1015
|
+
+ (f"git will use {f.email.value} from {_shown_origin(f.email, top)}" if f.email.value
|
|
1016
|
+
else "git will refuse to commit"))
|
|
1012
1017
|
lines.append(f" {e.match.rule}")
|
|
1013
1018
|
if f.email.value or f.name.value:
|
|
1014
1019
|
src = f.email if f.email.value else f.name
|
|
@@ -1053,6 +1058,17 @@ def register(sub, common, confirm) -> None:
|
|
|
1053
1058
|
capability("why", "why.config", "why.commit")
|
|
1054
1059
|
|
|
1055
1060
|
|
|
1061
|
+
def why_error(e: Exception, directory: Path) -> dict:
|
|
1062
|
+
"""The object `why --json` (and the MCP `why`) returns when there is no usable config: still an error,
|
|
1063
|
+
but say what git does here (the extension's status bar needs it)."""
|
|
1064
|
+
from . import cli
|
|
1065
|
+
try:
|
|
1066
|
+
data = cli.which_data(Path(directory))
|
|
1067
|
+
except config.ConfigError:
|
|
1068
|
+
data = {} # not a repo: the config error is the one to report
|
|
1069
|
+
return {"error": str(e), "config": None, **data}
|
|
1070
|
+
|
|
1071
|
+
|
|
1056
1072
|
def cmd_why(args) -> int:
|
|
1057
1073
|
require_git()
|
|
1058
1074
|
from . import cli, commitcheck
|
|
@@ -1064,12 +1080,7 @@ def cmd_why(args) -> int:
|
|
|
1064
1080
|
except config.ConfigError as e:
|
|
1065
1081
|
if not as_json or args.config:
|
|
1066
1082
|
raise
|
|
1067
|
-
|
|
1068
|
-
try:
|
|
1069
|
-
data = cli.which_data(Path(args.dir))
|
|
1070
|
-
except config.ConfigError:
|
|
1071
|
-
data = {} # not a repo: the config error is the one to report
|
|
1072
|
-
jsonout.emit("why", {"error": str(e), "config": None, **data}, ok=False)
|
|
1083
|
+
jsonout.emit("why", why_error(e, Path(args.dir)), ok=False)
|
|
1073
1084
|
return 2
|
|
1074
1085
|
rep = compare(Path(args.dir), cfg, remote=args.remote, home=home)
|
|
1075
1086
|
if as_json:
|
|
@@ -22,9 +22,11 @@ from .config import ConfigError, check
|
|
|
22
22
|
|
|
23
23
|
Snapshot = Optional[Tuple[bytes, os.stat_result]]
|
|
24
24
|
# agents.INSTALL_CHOICES + mcp.AGENTS (a test keeps them in step), and none
|
|
25
|
-
AGENTS = ("claude", "claude-desktop", "codex", "cursor", "gemini", "opencode", "none")
|
|
26
|
-
TITLE = {"claude": "Claude Code", "claude-desktop": "Claude Desktop", "codex": "Codex",
|
|
27
|
-
"gemini": "Gemini CLI", "opencode": "opencode"}
|
|
25
|
+
AGENTS = ("claude", "claude-desktop", "codex", "copilot", "cursor", "gemini", "opencode", "none")
|
|
26
|
+
TITLE = {"claude": "Claude Code", "claude-desktop": "Claude Desktop", "codex": "Codex",
|
|
27
|
+
"copilot": "GitHub Copilot CLI", "cursor": "Cursor", "gemini": "Gemini CLI", "opencode": "opencode"}
|
|
28
|
+
# Agents that gitpro has no hook or MCP registration for, and why (shown when one is asked for by name).
|
|
29
|
+
NO_COVERAGE = {"codex": "gitpro has no hook or MCP registration for Codex"}
|
|
28
30
|
QUESTION = "Apply these changes?"
|
|
29
31
|
LEARN = "Look through your repos to find your identities?"
|
|
30
32
|
LEARN_BUDGET = 20.0 # seconds for inspecting the repos found
|
|
@@ -362,9 +364,14 @@ class Work:
|
|
|
362
364
|
mcps: List[Tuple[str, str]] = field(default_factory=list)
|
|
363
365
|
done: List[str] = field(default_factory=list) # already in place
|
|
364
366
|
notes: List[str] = field(default_factory=list) # left alone, and why
|
|
367
|
+
coverage: List[dict] = field(default_factory=list) # per agent: hook and MCP status (see inspect)
|
|
368
|
+
uncovered: List[str] = field(default_factory=list) # agents asked for by name that gitpro cannot cover
|
|
369
|
+
skills: List[dict] = field(default_factory=list) # installed gitpro skill copies: {"path","state"}
|
|
370
|
+
skill_stale: list = field(default_factory=list) # skillcopy.Copy that differ from the bundled skill
|
|
371
|
+
skill_text: Optional[bytes] = None # the bundled skill
|
|
365
372
|
|
|
366
373
|
def pending(self) -> bool:
|
|
367
|
-
return bool(self.config or self.keys or self.changes or self.hooks or self.mcps)
|
|
374
|
+
return bool(self.config or self.keys or self.changes or self.hooks or self.mcps or self.skill_stale)
|
|
368
375
|
|
|
369
376
|
|
|
370
377
|
def _empty(cfg: config.Config) -> bool:
|
|
@@ -405,8 +412,35 @@ def _agents(args, home: Path) -> List[str]:
|
|
|
405
412
|
return ["claude"] if (home / ".claude").is_dir() or shutil.which("claude") else []
|
|
406
413
|
|
|
407
414
|
|
|
408
|
-
def
|
|
409
|
-
"""
|
|
415
|
+
def _skills(w: Work, home: Path) -> None:
|
|
416
|
+
"""Which installed gitpro skill copies differ from the one bundled with this gitpro (spec 23 section 5).
|
|
417
|
+
Reads only. A copy that is not there is not reported: setup never creates one."""
|
|
418
|
+
from . import skillcopy
|
|
419
|
+
w.skill_text = skillcopy.bundled()
|
|
420
|
+
if w.skill_text is None:
|
|
421
|
+
w.notes.append("the gitpro skill is not bundled with this install; installed copies not checked")
|
|
422
|
+
return
|
|
423
|
+
for c in skillcopy.copies(home, w.skill_text):
|
|
424
|
+
w.skills.append({"path": str(c.path), "state": c.state})
|
|
425
|
+
if c.state == "differs":
|
|
426
|
+
w.skill_stale.append(c)
|
|
427
|
+
elif c.state == "unreadable":
|
|
428
|
+
w.notes.append(f"{c.path}: the gitpro skill is there but cannot be read; left alone")
|
|
429
|
+
else:
|
|
430
|
+
w.done.append(f"gitpro skill {undo_tilde(c.path)}")
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def undo_tilde(path: Path) -> str:
|
|
434
|
+
from . import undo
|
|
435
|
+
return undo.tilde(path)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def inspect(cfg: config.Config, env, names: List[str], explicit: bool = False) -> Work:
|
|
439
|
+
"""What the files, the guard and the agents still need; reads only. For each agent in names, w.coverage
|
|
440
|
+
has {"agent", "hook", "mcp"[, "reason"]}, each status one of "installed" (gitpro's entry is in the
|
|
441
|
+
agent's config file), "plugin" (a plugin that ships it is enabled), "missing" (setup would add it) or
|
|
442
|
+
"unavailable". None of these says the agent loaded it. With explicit (the user named the agents), an
|
|
443
|
+
agent with neither a hook nor an MCP server available goes to w.uncovered."""
|
|
410
444
|
from . import agents, keys, mcp, plan
|
|
411
445
|
w = Work()
|
|
412
446
|
for k in keys.inventory(cfg.default, list(cfg.identities)):
|
|
@@ -418,6 +452,7 @@ def inspect(cfg: config.Config, env, names: List[str]) -> Work:
|
|
|
418
452
|
"setup does not create a key over it")
|
|
419
453
|
else:
|
|
420
454
|
w.keys.append(k)
|
|
455
|
+
_skills(w, env.home)
|
|
421
456
|
w.guard, why = _guard(cfg, env)
|
|
422
457
|
if why:
|
|
423
458
|
w.notes.append(why)
|
|
@@ -430,22 +465,41 @@ def inspect(cfg: config.Config, env, names: List[str]) -> Work:
|
|
|
430
465
|
title = TITLE[a]
|
|
431
466
|
if a == "claude" and agents.plugin_enabled(env.home):
|
|
432
467
|
w.done.append("Claude Code: the gitpro plugin provides the hook and the MCP server")
|
|
468
|
+
w.coverage.append({"agent": a, "hook": "plugin", "mcp": "plugin"})
|
|
433
469
|
continue
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
470
|
+
row = {"agent": a}
|
|
471
|
+
reasons: List[str] = []
|
|
472
|
+
for what, key, change, todo, known in (("hook", "hook", agents.hook_change, w.hooks, agents.HOOK_AGENTS),
|
|
473
|
+
("MCP server", "mcp", mcp.install_change, w.mcps, mcp.AGENTS)):
|
|
474
|
+
if a not in known:
|
|
475
|
+
row[key] = "unavailable"
|
|
476
|
+
continue
|
|
440
477
|
try:
|
|
441
478
|
line = change(a, env.home)
|
|
442
479
|
except ConfigError as e:
|
|
480
|
+
row[key] = "unavailable"
|
|
481
|
+
reasons.append(f"{what} not set up: {e}")
|
|
443
482
|
w.notes.append(f"{title} {what} not set up: {e}")
|
|
444
483
|
continue
|
|
445
484
|
if line:
|
|
485
|
+
row[key] = "missing"
|
|
446
486
|
todo.append((a, line))
|
|
447
487
|
else:
|
|
488
|
+
row[key] = "installed"
|
|
448
489
|
w.done.append(f"{title} {what}")
|
|
490
|
+
if row["hook"] == "unavailable" and row["mcp"] == "unavailable":
|
|
491
|
+
why = NO_COVERAGE.get(a) or "; ".join(reasons) or f"gitpro has no hook or MCP entry for {title}"
|
|
492
|
+
reasons = [why]
|
|
493
|
+
if explicit:
|
|
494
|
+
w.uncovered.append(f"{title}: {why}; the commit guard still checks its commits")
|
|
495
|
+
else:
|
|
496
|
+
w.notes.append(f"{title}: {why}; the commit guard still checks its commits")
|
|
497
|
+
elif a not in agents.HOOK_AGENTS or a not in mcp.AGENTS:
|
|
498
|
+
reasons.append("gitpro has no " + ("hook" if a not in agents.HOOK_AGENTS else "MCP registration")
|
|
499
|
+
+ f" for {title}")
|
|
500
|
+
if reasons:
|
|
501
|
+
row["reason"] = "; ".join(reasons)
|
|
502
|
+
w.coverage.append(row)
|
|
449
503
|
return w
|
|
450
504
|
|
|
451
505
|
|
|
@@ -474,6 +528,8 @@ def rows(w: Work, env, passphrase: bool) -> List[Tuple[str, str]]:
|
|
|
474
528
|
"for the right identity)"))
|
|
475
529
|
rows += [(f"{TITLE[a]} hook", f"{line} (checks the git commands it runs)") for a, line in w.hooks]
|
|
476
530
|
rows += [(f"{TITLE[a]} MCP", f"{line} (read-only identity tools)") for a, line in w.mcps]
|
|
531
|
+
rows += [("gitpro skill", f"update {undo.tilde(c.path)} to this gitpro's version (a backup is kept)")
|
|
532
|
+
for c in w.skill_stale]
|
|
477
533
|
order = ["config", "SSH key", "Git, SSH", "guard"]
|
|
478
534
|
rows.sort(key=lambda r: order.index(r[0]) if r[0] in order else len(order)) # stable: agents keep theirs
|
|
479
535
|
return rows
|
|
@@ -497,6 +553,8 @@ def show(out, w: Work, env, passphrase: bool) -> None:
|
|
|
497
553
|
out.print(Text("Already in place: " + "; ".join(w.done), style="dim"), soft_wrap=True)
|
|
498
554
|
for n in w.notes:
|
|
499
555
|
out.print(Text("⚠ " + n, style="yellow"), soft_wrap=True)
|
|
556
|
+
for n in w.uncovered:
|
|
557
|
+
out.print(Text("✗ " + n, style="red"), soft_wrap=True)
|
|
500
558
|
|
|
501
559
|
|
|
502
560
|
def _apply_files(cfg: config.Config, env, guard: bool) -> None:
|
|
@@ -513,6 +571,34 @@ def _apply_files(cfg: config.Config, env, guard: bool) -> None:
|
|
|
513
571
|
print(f"↩ undo: sh {undo.tilde(script)}")
|
|
514
572
|
|
|
515
573
|
|
|
574
|
+
def _apply_skills(w: Work) -> int:
|
|
575
|
+
"""Update the stale skill copies: each is written through its link (never replacing it), after a backup
|
|
576
|
+
in the state folder: a file beside SKILL.md would sit in the skills folder, which may be a synced
|
|
577
|
+
or committed store. Returns the number that failed."""
|
|
578
|
+
from . import files, undo, view
|
|
579
|
+
failed = 0
|
|
580
|
+
lines: List[str] = []
|
|
581
|
+
for c in w.skill_stale:
|
|
582
|
+
try:
|
|
583
|
+
current = c.path.read_bytes()
|
|
584
|
+
if c.old is None or current.decode("utf-8") != c.old:
|
|
585
|
+
raise OSError("it changed since it was checked")
|
|
586
|
+
into = undo.state_dir() / "skill-backups"
|
|
587
|
+
into.mkdir(mode=0o700, exist_ok=True)
|
|
588
|
+
bak = files._backup(c.path, current, into=into)
|
|
589
|
+
lines.append(f"cp -p {shlex.quote(str(bak))} {shlex.quote(str(c.path))}")
|
|
590
|
+
files.atomic_write(c.path, w.skill_text or b"", c.mode)
|
|
591
|
+
except (OSError, UnicodeDecodeError) as e:
|
|
592
|
+
failed += 1
|
|
593
|
+
view.error(f"{c.path}: skill not updated: {e}")
|
|
594
|
+
continue
|
|
595
|
+
view.ok(f"updated {undo.tilde(c.path)} (backup: {undo.tilde(bak)})")
|
|
596
|
+
if lines:
|
|
597
|
+
script = undo.write("setup", lines, what="gitpro skill copies")
|
|
598
|
+
print(f"↩ undo: sh {undo.tilde(script)}")
|
|
599
|
+
return failed
|
|
600
|
+
|
|
601
|
+
|
|
516
602
|
def _identity_json(i, d) -> dict:
|
|
517
603
|
return {"id": i.id, "name": i.name, "email": i.email, "host": i.host, "owners": list(i.owners),
|
|
518
604
|
"ssh_alias": i.ssh_alias, "ssh_key": i.ssh_key, "gh_account": i.gh_account,
|
|
@@ -529,12 +615,12 @@ def state(args, home: Path, path: Path, cfg: Optional[config.Config], names: Lis
|
|
|
529
615
|
todo = [{"group": "config", "what": f"no identities in {undo.tilde(path)} yet: run gitpro setup in a terminal"}]
|
|
530
616
|
if args.json:
|
|
531
617
|
jsonout.emit("setup", {"dry_run": True, "config": str(path), "identities": [], "default": None,
|
|
532
|
-
"pending": todo, "done": [], "notes": []}, ok=False)
|
|
618
|
+
"pending": todo, "done": [], "notes": [], "agents": [], "uncovered": [], "skills": []}, ok=False)
|
|
533
619
|
else:
|
|
534
620
|
print(f"Config: {path}")
|
|
535
621
|
view.warn(todo[0]["what"])
|
|
536
622
|
return 1
|
|
537
|
-
w = inspect(cfg, env, names)
|
|
623
|
+
w = inspect(cfg, env, names, explicit=bool(args.agent))
|
|
538
624
|
pending = [{"group": g, "what": what} for g, what in rows(w, env, passphrase=not args.no_passphrase)]
|
|
539
625
|
warnings = list(lint.warnings(cfg.default, list(cfg.identities), env))
|
|
540
626
|
if args.json:
|
|
@@ -544,8 +630,9 @@ def state(args, home: Path, path: Path, cfg: Optional[config.Config], names: Lis
|
|
|
544
630
|
"identities": [_identity_json(i, d) for i in cfg.identities],
|
|
545
631
|
"default": {"name": d.name, "email": d.email, "gh_account": d.gh_account, "glab_account": d.glab_account,
|
|
546
632
|
"clone_dir": d.clone_dir, "sign": d.sign},
|
|
547
|
-
"pending": pending, "done": w.done, "notes": w.notes + warnings
|
|
548
|
-
|
|
633
|
+
"pending": pending, "done": w.done, "notes": w.notes + warnings, "agents": w.coverage,
|
|
634
|
+
"uncovered": w.uncovered, "skills": w.skills}, ok=not (pending or w.uncovered))
|
|
635
|
+
return 1 if pending or w.uncovered else 0
|
|
549
636
|
out = view.console()
|
|
550
637
|
out.print(Text(f"Config: {cfg.path}"))
|
|
551
638
|
for identity in cfg.identities:
|
|
@@ -561,6 +648,8 @@ def state(args, home: Path, path: Path, cfg: Optional[config.Config], names: Lis
|
|
|
561
648
|
if pending:
|
|
562
649
|
view.step("to do it: gitpro setup")
|
|
563
650
|
return 1
|
|
651
|
+
if w.uncovered:
|
|
652
|
+
return 1
|
|
564
653
|
view.ok("this machine is set up; nothing to change")
|
|
565
654
|
return 0
|
|
566
655
|
|
|
@@ -619,14 +708,14 @@ def cmd_setup(args) -> int:
|
|
|
619
708
|
out.print(Syntax(text[len(original):] if added else text, "toml", word_wrap=True))
|
|
620
709
|
|
|
621
710
|
env = plan.Env(home)
|
|
622
|
-
w = inspect(cfg, env, names)
|
|
711
|
+
w = inspect(cfg, env, names, explicit=bool(args.agent))
|
|
623
712
|
if text != original:
|
|
624
713
|
w.config = (f"create {undo.tilde(path)}" if before is None else
|
|
625
714
|
f"update {undo.tilde(path)} (a backup is kept)")
|
|
626
715
|
show(out, w, env, passphrase=not args.no_passphrase)
|
|
627
716
|
failed = 0
|
|
628
717
|
if not w.pending():
|
|
629
|
-
view.ok("this machine is set up; nothing to change")
|
|
718
|
+
view.ok("nothing to change" if w.uncovered else "this machine is set up; nothing to change")
|
|
630
719
|
else:
|
|
631
720
|
out.print(Text("Repos are not changed: 'gitpro scan' lists the ones that need a fix.", style="dim"))
|
|
632
721
|
check(not w.keys or args.no_passphrase or tty,
|
|
@@ -656,8 +745,9 @@ def cmd_setup(args) -> int:
|
|
|
656
745
|
for a, _ in w.mcps:
|
|
657
746
|
failed += mcp.cmd_install(argparse.Namespace(
|
|
658
747
|
agent=a, scope="user", home=home, json=False, yes=True, write=True, force=False)) != 0
|
|
748
|
+
failed += _apply_skills(w)
|
|
659
749
|
if failed:
|
|
660
|
-
view.error("
|
|
750
|
+
view.error("a step failed (above); run gitpro setup again after fixing it")
|
|
661
751
|
for msg in lint.warnings(cfg.default, list(cfg.identities), env):
|
|
662
752
|
view.warn(msg)
|
|
663
753
|
inv = keys.inventory(cfg.default, list(cfg.identities))
|
|
@@ -665,4 +755,4 @@ def cmd_setup(args) -> int:
|
|
|
665
755
|
out.print()
|
|
666
756
|
keys.report(inv, env.expand)
|
|
667
757
|
view.step("check your repos: gitpro scan (it changes nothing; it shows each fix)")
|
|
668
|
-
return 1 if failed else 0
|
|
758
|
+
return 1 if failed or w.uncovered else 0
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""The gitpro skill that ships inside the package, and the copies of it that agents already have.
|
|
2
|
+
|
|
3
|
+
`setup -n` reports an installed copy that differs from the bundled one; `setup -y` updates the copies that
|
|
4
|
+
exist and never creates one (spec 23 section 5). Standard library only."""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from importlib import resources
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import List, Optional
|
|
12
|
+
|
|
13
|
+
# Where agents look for a user-level skill, relative to the home folder. ~/.claude/skills and
|
|
14
|
+
# ~/.copilot/skills are often links to ~/.agents/skills: one physical file counts once.
|
|
15
|
+
LOCATIONS = (".claude/skills/gitpro", ".copilot/skills/gitpro", ".agents/skills/gitpro")
|
|
16
|
+
NAME = "SKILL.md"
|
|
17
|
+
PACKAGED = "skill/SKILL.md" # inside the wheel (pyproject force-include)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def bundled() -> Optional[bytes]:
|
|
21
|
+
"""The skill of this gitpro: the packaged copy, else (a source checkout) skills/gitpro/SKILL.md."""
|
|
22
|
+
try:
|
|
23
|
+
return resources.files("gitpro").joinpath(PACKAGED).read_bytes()
|
|
24
|
+
except (OSError, ModuleNotFoundError):
|
|
25
|
+
pass
|
|
26
|
+
try:
|
|
27
|
+
return (Path(__file__).resolve().parents[2] / "skills" / "gitpro" / NAME).read_bytes()
|
|
28
|
+
except OSError:
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Copy:
|
|
34
|
+
path: Path # the physical file (symlinks resolved)
|
|
35
|
+
locations: List[Path] = field(default_factory=list) # the paths under home that lead to it
|
|
36
|
+
state: str = "current" # current | differs | unreadable
|
|
37
|
+
old: Optional[str] = None # its text, when it differs
|
|
38
|
+
mode: int = 0o644
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def copies(home: Path, want: Optional[bytes]) -> List[Copy]:
|
|
42
|
+
"""The installed copies under home, each physical file once, in LOCATIONS order. A location with no
|
|
43
|
+
SKILL.md (or a dangling link) is not a copy. Reads only."""
|
|
44
|
+
found: dict = {}
|
|
45
|
+
for loc in LOCATIONS:
|
|
46
|
+
link = home / loc / NAME
|
|
47
|
+
try:
|
|
48
|
+
if not link.is_file():
|
|
49
|
+
continue
|
|
50
|
+
real = Path(os.path.realpath(link))
|
|
51
|
+
except OSError:
|
|
52
|
+
continue
|
|
53
|
+
c = found.get(real)
|
|
54
|
+
if c is None:
|
|
55
|
+
c = found[real] = Copy(real)
|
|
56
|
+
try:
|
|
57
|
+
data = real.read_bytes()
|
|
58
|
+
c.mode = real.stat().st_mode & 0o777
|
|
59
|
+
if want is not None and data != want:
|
|
60
|
+
c.state = "differs"
|
|
61
|
+
c.old = data.decode("utf-8")
|
|
62
|
+
except (OSError, UnicodeDecodeError):
|
|
63
|
+
c.state = "unreadable"
|
|
64
|
+
c.locations.append(link)
|
|
65
|
+
return list(found.values())
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|