pi-forgejo-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/CONTRIBUTING.md +79 -0
- package/LICENSE +21 -0
- package/README.md +221 -0
- package/SECURITY.md +40 -0
- package/extensions/forgejo-mcp/cli.ts +308 -0
- package/extensions/forgejo-mcp/index.ts +138 -0
- package/extensions/forgejo-mcp/mutations.ts +58 -0
- package/extensions/forgejo-mcp.ts +1 -0
- package/package.json +64 -0
package/CHANGELOG.md
ADDED
package/CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
Thanks for your interest in contributing to `pi-forgejo-mcp`.
|
|
4
|
+
|
|
5
|
+
## Prerequisites
|
|
6
|
+
|
|
7
|
+
- Node.js `>=22.19.0`
|
|
8
|
+
- npm
|
|
9
|
+
- Pi installed locally for interactive extension testing
|
|
10
|
+
- `forgejo-mcp` on your `PATH` if you want to test against a real Forgejo instance
|
|
11
|
+
|
|
12
|
+
Install dependencies with:
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
npm ci
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Local development
|
|
19
|
+
|
|
20
|
+
Run the full check suite before opening a pull request:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
npm run check
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
This runs TypeScript, ESLint, Prettier, and Vitest.
|
|
27
|
+
|
|
28
|
+
For interactive local testing:
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
pi -e .
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Formatting
|
|
35
|
+
|
|
36
|
+
Check formatting with:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
npm run format
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Apply formatting with:
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
npm run format:write
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Testing with Forgejo
|
|
49
|
+
|
|
50
|
+
For real Forgejo calls, set credentials in the environment of the shell that launches Pi:
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
export FORGEJO_URL="https://codeberg.org"
|
|
54
|
+
export FORGEJO_ACCESS_TOKEN="<your personal access token>"
|
|
55
|
+
pi -e .
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Never commit real tokens. `.env` and `.env.*` files are gitignored for local use, and Pi does not load them automatically.
|
|
59
|
+
|
|
60
|
+
## Pull requests
|
|
61
|
+
|
|
62
|
+
Please keep changes focused and include relevant updates when applicable:
|
|
63
|
+
|
|
64
|
+
- tests for behavior changes
|
|
65
|
+
- documentation for user-facing changes
|
|
66
|
+
- changelog entries for release-worthy changes
|
|
67
|
+
- security notes for changes affecting token handling, command execution, or mutations
|
|
68
|
+
|
|
69
|
+
Before submitting, run:
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
npm run check
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Security
|
|
76
|
+
|
|
77
|
+
Please do not include access tokens, private repository details, issue contents, or other sensitive data in issues, pull requests, tests, or logs.
|
|
78
|
+
|
|
79
|
+
See [SECURITY.md](SECURITY.md) for vulnerability reporting and token-handling guidance.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ben Osborne
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# pi-forgejo-mcp
|
|
2
|
+
|
|
3
|
+
A [Pi](https://pi.dev/) extension for using Forgejo from Pi via the official [`forgejo-mcp`](https://codeberg.org/goern/forgejo-mcp) CLI.
|
|
4
|
+
|
|
5
|
+
Use it to ask Pi to list repositories, inspect issues, create pull requests, read files, check workflow runs, manage releases, and call other Forgejo operations exposed by `forgejo-mcp`.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
First install the official Forgejo MCP CLI:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
go install codeberg.org/goern/forgejo-mcp/v2@latest
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Make sure `forgejo-mcp` is on your `PATH`:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
forgejo-mcp --help
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### Install by asking Pi
|
|
22
|
+
|
|
23
|
+
If you already have Pi installed, you can paste this prompt into Pi. It asks Pi to check prerequisites, install the extension, and then explain the remaining authentication steps:
|
|
24
|
+
|
|
25
|
+
```text
|
|
26
|
+
Please install the Forgejo MCP Pi extension for me.
|
|
27
|
+
|
|
28
|
+
Before installing, remind me that Pi packages/extensions run with my local user permissions. Ask me for confirmation before running installation commands.
|
|
29
|
+
|
|
30
|
+
Do the setup:
|
|
31
|
+
1. Check whether `forgejo-mcp` is installed and on PATH.
|
|
32
|
+
2. If it is missing, check whether `go` is available. If Go is available, install the official CLI with:
|
|
33
|
+
`go install codeberg.org/goern/forgejo-mcp/v2@latest`
|
|
34
|
+
Then verify `forgejo-mcp --help` works. If it does not, tell me to add `$(go env GOPATH)/bin` to PATH or configure `FORGEJO_MCP_COMMAND`.
|
|
35
|
+
If Go is not available, stop and tell me to install Go or configure `FORGEJO_MCP_COMMAND`.
|
|
36
|
+
3. Install the Pi package:
|
|
37
|
+
`pi install git:codeberg.org/ozzy92/pi-forgejo-mcp@main`
|
|
38
|
+
4. Do not ask me to paste a token into chat. Tell me to set `FORGEJO_URL` and `FORGEJO_ACCESS_TOKEN` in the shell that launches Pi, using 1Password, Bitwarden, direnv, or another secret store.
|
|
39
|
+
5. Tell me to restart Pi or run `/reload`.
|
|
40
|
+
6. After reload/restart, tell me to run: `Check Forgejo MCP status.`
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
For repeatable installs, ask Pi to install a tagged release such as `git:codeberg.org/ozzy92/pi-forgejo-mcp@v0.1.0` instead of `main`.
|
|
44
|
+
|
|
45
|
+
### Manual install
|
|
46
|
+
|
|
47
|
+
Install this Pi package from Codeberg:
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
pi install git:codeberg.org/ozzy92/pi-forgejo-mcp@main
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
For repeatable installs, install a tagged release instead of `main`:
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
pi install git:codeberg.org/ozzy92/pi-forgejo-mcp@v0.1.0
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Restart Pi or run `/reload` in an existing Pi session.
|
|
60
|
+
|
|
61
|
+
> **Security:** Pi packages and extensions run with your local user permissions. Review the source before installing third-party packages.
|
|
62
|
+
|
|
63
|
+
## Authenticate with Forgejo
|
|
64
|
+
|
|
65
|
+
Create a Forgejo personal access token with the permissions you need for the operations you want Pi to perform.
|
|
66
|
+
|
|
67
|
+
The token lives in the environment of the `pi` process:
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
export FORGEJO_URL="https://codeberg.org" # or your Forgejo instance URL
|
|
71
|
+
export FORGEJO_ACCESS_TOKEN="<your personal access token>"
|
|
72
|
+
pi
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Do **not** commit the token. Do not put it in this repository, `package.json`, Pi settings, prompts, or session files.
|
|
76
|
+
|
|
77
|
+
Good places to keep the token:
|
|
78
|
+
|
|
79
|
+
- your password manager, injected into the shell before launching Pi
|
|
80
|
+
- a local, gitignored shell file sourced by your shell profile
|
|
81
|
+
- a [`direnv`](https://direnv.net/) `.envrc` file that is not committed
|
|
82
|
+
- CI/secret-store environment variables for non-interactive runs
|
|
83
|
+
|
|
84
|
+
Example with 1Password CLI:
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
export FORGEJO_URL="https://codeberg.org"
|
|
88
|
+
export FORGEJO_ACCESS_TOKEN="$(op read 'op://Private/Codeberg Forgejo/token')"
|
|
89
|
+
pi
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Example with Bitwarden CLI:
|
|
93
|
+
|
|
94
|
+
```sh
|
|
95
|
+
export BW_SESSION="$(bw unlock --raw)"
|
|
96
|
+
export FORGEJO_URL="https://codeberg.org"
|
|
97
|
+
export FORGEJO_ACCESS_TOKEN="$(bw get password 'Codeberg Forgejo token')"
|
|
98
|
+
pi
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
See [SECURITY.md](SECURITY.md) for vulnerability reporting, token-handling guidance, and local execution notes.
|
|
102
|
+
|
|
103
|
+
## Try it in Pi
|
|
104
|
+
|
|
105
|
+
Ask Pi things like:
|
|
106
|
+
|
|
107
|
+
```text
|
|
108
|
+
Check Forgejo MCP status.
|
|
109
|
+
List Forgejo MCP issue tools.
|
|
110
|
+
Describe the list_repo_issues Forgejo MCP tool.
|
|
111
|
+
List open issues in my-org/my-repo.
|
|
112
|
+
Create an issue in my-org/my-repo titled "Improve docs" with body "Add screenshots".
|
|
113
|
+
Show recent workflow runs for my-org/my-repo.
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Pi will usually discover or describe the Forgejo operation first, then call it with JSON arguments.
|
|
117
|
+
|
|
118
|
+
## Tools provided to Pi
|
|
119
|
+
|
|
120
|
+
When Pi loads this extension, it registers four tools:
|
|
121
|
+
|
|
122
|
+
| Tool | Purpose |
|
|
123
|
+
| --------------------------- | --------------------------------------------------------------------------------------------------- |
|
|
124
|
+
| `forgejo_mcp_status` | Check whether `forgejo-mcp` is installed and whether runtime auth env vars are present. |
|
|
125
|
+
| `forgejo_mcp_list_tools` | List operations exposed by the installed `forgejo-mcp` binary, optionally filtered by domain/query. |
|
|
126
|
+
| `forgejo_mcp_describe_tool` | Show the parameters for one Forgejo MCP operation. |
|
|
127
|
+
| `forgejo_mcp_call` | Invoke one Forgejo MCP operation with JSON arguments. |
|
|
128
|
+
|
|
129
|
+
If you are driving Pi programmatically, `forgejo_mcp_call` accepts this shape:
|
|
130
|
+
|
|
131
|
+
```json
|
|
132
|
+
{
|
|
133
|
+
"tool": "list_repo_issues",
|
|
134
|
+
"args": {
|
|
135
|
+
"owner": "my-org",
|
|
136
|
+
"repo": "my-repo",
|
|
137
|
+
"state": "open"
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## How it works
|
|
143
|
+
|
|
144
|
+
This extension does not implement the Forgejo API itself. It shells out to the official CLI:
|
|
145
|
+
|
|
146
|
+
```sh
|
|
147
|
+
forgejo-mcp --cli <operation> --args '<json>' --output=json
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Read-only discovery (`forgejo_mcp_list_tools` and `forgejo_mcp_describe_tool`) uses dummy local environment variables because `forgejo-mcp` requires configuration even for local help output.
|
|
151
|
+
|
|
152
|
+
Real Forgejo calls use `FORGEJO_URL` and `FORGEJO_ACCESS_TOKEN` from the Pi process environment.
|
|
153
|
+
|
|
154
|
+
Large outputs are truncated to Pi's default tool-output limit, and the full output is written to a temp file. Captured stdout/stderr is sanitized so the configured access token is replaced before tool results are returned to Pi.
|
|
155
|
+
|
|
156
|
+
## Safety model
|
|
157
|
+
|
|
158
|
+
Operations whose names look mutating (`create_*`, `update_*`, `delete_*`, `merge_*`, `mark_*`, etc.) ask for confirmation in interactive Pi sessions.
|
|
159
|
+
|
|
160
|
+
In non-interactive modes, mutating operations are blocked unless you explicitly set:
|
|
161
|
+
|
|
162
|
+
```sh
|
|
163
|
+
export FORGEJO_MCP_ALLOW_MUTATIONS=true
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
You can disable interactive confirmations with:
|
|
167
|
+
|
|
168
|
+
```sh
|
|
169
|
+
export FORGEJO_MCP_CONFIRM_MUTATIONS=false
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
When confirmations are disabled, mutating operations are treated like non-interactive runs and still require:
|
|
173
|
+
|
|
174
|
+
```sh
|
|
175
|
+
export FORGEJO_MCP_ALLOW_MUTATIONS=true
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## Configuration
|
|
179
|
+
|
|
180
|
+
| Variable | Default | Purpose |
|
|
181
|
+
| ------------------------------- | ----------------- | --------------------------------------------------------------- |
|
|
182
|
+
| `FORGEJO_URL` | required | Forgejo instance URL, for example `https://codeberg.org`. |
|
|
183
|
+
| `FORGEJO_ACCESS_TOKEN` | required | Forgejo personal access token. |
|
|
184
|
+
| `FORGEJO_MCP_COMMAND` | `forgejo-mcp` | Binary to execute. Use this if `forgejo-mcp` is not on `PATH`. |
|
|
185
|
+
| `FORGEJO_MCP_TIMEOUT_MS` | `60000` | Timeout per CLI call. |
|
|
186
|
+
| `FORGEJO_MCP_MAX_CAPTURE_BYTES` | `26214400` | Maximum stdout/stderr captured before killing the process. |
|
|
187
|
+
| `FORGEJO_MCP_CONFIRM_MUTATIONS` | `true` in UI mode | Set to `false` to skip interactive confirmation. |
|
|
188
|
+
| `FORGEJO_MCP_ALLOW_MUTATIONS` | unset | Required for mutating operations when no confirmation is taken. |
|
|
189
|
+
|
|
190
|
+
## Local development
|
|
191
|
+
|
|
192
|
+
```sh
|
|
193
|
+
git clone https://codeberg.org/ozzy92/pi-forgejo-mcp.git
|
|
194
|
+
cd pi-forgejo-mcp
|
|
195
|
+
npm install
|
|
196
|
+
npm run check
|
|
197
|
+
pi -e .
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
`npm run check` runs TypeScript, ESLint, Prettier, and Vitest.
|
|
201
|
+
|
|
202
|
+
For local testing against a real Forgejo instance, copy the example environment file and source it before launching Pi:
|
|
203
|
+
|
|
204
|
+
```sh
|
|
205
|
+
cp .env.example .env.local
|
|
206
|
+
# edit .env.local with your own token
|
|
207
|
+
set -a; source .env.local; set +a
|
|
208
|
+
pi -e .
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Pi does not automatically load `.env` files. The `.env` and `.env.*` patterns are gitignored; keep real tokens out of commits.
|
|
212
|
+
|
|
213
|
+
Or install the local package globally while developing:
|
|
214
|
+
|
|
215
|
+
```sh
|
|
216
|
+
pi install /absolute/path/to/pi-forgejo-mcp
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
## License
|
|
220
|
+
|
|
221
|
+
MIT
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Security
|
|
2
|
+
|
|
3
|
+
`pi-forgejo-mcp` is an open-source Pi extension that shells out to the official `forgejo-mcp` CLI. It does not implement Forgejo authentication itself.
|
|
4
|
+
|
|
5
|
+
## Supported versions
|
|
6
|
+
|
|
7
|
+
Security fixes are intended for the latest released version and the `main` branch. Older tags may not receive backports.
|
|
8
|
+
|
|
9
|
+
## Reporting vulnerabilities
|
|
10
|
+
|
|
11
|
+
Please do not publish access tokens, private repository names, issue contents, or other sensitive data in public reports.
|
|
12
|
+
|
|
13
|
+
- For issues that can be discussed publicly, open a Codeberg issue: <https://codeberg.org/ozzy92/pi-forgejo-mcp/issues>
|
|
14
|
+
- For sensitive vulnerability details, use any private contact method listed on the project or maintainer profile first. If no private channel is available, open a minimal public issue asking for a private contact path and include only high-level impact and affected components.
|
|
15
|
+
|
|
16
|
+
## Secret handling
|
|
17
|
+
|
|
18
|
+
The extension reads Forgejo credentials from the environment of the running Pi process:
|
|
19
|
+
|
|
20
|
+
- `FORGEJO_URL`
|
|
21
|
+
- `FORGEJO_ACCESS_TOKEN`
|
|
22
|
+
|
|
23
|
+
The extension does not read `.env` files automatically, does not write tokens to Pi settings, and does not require tokens in tool-call arguments.
|
|
24
|
+
|
|
25
|
+
Captured stdout/stderr is sanitized before being returned to Pi by replacing the configured access token with `<FORGEJO_ACCESS_TOKEN>`. Do not rely on sanitization as your only control: avoid pasting secrets into prompts, issue bodies, pull request text, or tool arguments.
|
|
26
|
+
|
|
27
|
+
## Token guidance
|
|
28
|
+
|
|
29
|
+
- Use a token with the least permissions needed for the operations you want Pi to perform.
|
|
30
|
+
- Store the token in a password manager, `direnv`, your shell environment, or a CI/secret-store environment variable.
|
|
31
|
+
- Never commit real tokens or include them in examples, prompts, logs, issues, or pull requests.
|
|
32
|
+
- Rotate the token immediately if it is exposed.
|
|
33
|
+
|
|
34
|
+
## Local execution trust
|
|
35
|
+
|
|
36
|
+
Pi packages and extensions run with your local user permissions. Only install this package, Pi, and the `forgejo-mcp` binary from sources you trust.
|
|
37
|
+
|
|
38
|
+
This extension executes `forgejo-mcp` from `PATH` by default, or from `FORGEJO_MCP_COMMAND` if set. Forgejo operations run with the permissions granted to `FORGEJO_ACCESS_TOKEN`.
|
|
39
|
+
|
|
40
|
+
Mutating Forgejo operations ask for confirmation in interactive Pi sessions. If no confirmation is taken, either because Pi is non-interactive or `FORGEJO_MCP_CONFIRM_MUTATIONS=false` is set, mutating operations are blocked unless `FORGEJO_MCP_ALLOW_MUTATIONS=true` is set.
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { mkdtemp, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_MAX_BYTES,
|
|
8
|
+
DEFAULT_MAX_LINES,
|
|
9
|
+
formatSize,
|
|
10
|
+
truncateHead,
|
|
11
|
+
type TruncationResult,
|
|
12
|
+
} from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import { confirmMutationIfNeeded } from "./mutations.ts";
|
|
14
|
+
|
|
15
|
+
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
16
|
+
const DEFAULT_MAX_CAPTURE_BYTES = 25 * 1024 * 1024;
|
|
17
|
+
const DUMMY_FORGEJO_URL = "https://example.invalid";
|
|
18
|
+
const DUMMY_FORGEJO_TOKEN = "dummy-token-for-tool-discovery";
|
|
19
|
+
|
|
20
|
+
const toolNamePattern = /^[a-z0-9_]+$/;
|
|
21
|
+
|
|
22
|
+
export type ForgejoToolSummary = {
|
|
23
|
+
name: string;
|
|
24
|
+
description?: string;
|
|
25
|
+
domain?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type CommandResult = {
|
|
29
|
+
stdout: string;
|
|
30
|
+
stderr: string;
|
|
31
|
+
code: number | null;
|
|
32
|
+
signal: NodeJS.Signals | null;
|
|
33
|
+
timedOut: boolean;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type FormattedOutput = {
|
|
37
|
+
text: string;
|
|
38
|
+
details: {
|
|
39
|
+
truncated: boolean;
|
|
40
|
+
truncation?: TruncationResult;
|
|
41
|
+
fullOutputPath?: string;
|
|
42
|
+
stderr?: string;
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export function getCommand(): string {
|
|
47
|
+
return process.env.FORGEJO_MCP_COMMAND?.trim() || "forgejo-mcp";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getTimeoutMs(): number {
|
|
51
|
+
const value = Number.parseInt(process.env.FORGEJO_MCP_TIMEOUT_MS ?? "", 10);
|
|
52
|
+
return Number.isFinite(value) && value > 0 ? value : DEFAULT_TIMEOUT_MS;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function getMaxCaptureBytes(): number {
|
|
56
|
+
const value = Number.parseInt(process.env.FORGEJO_MCP_MAX_CAPTURE_BYTES ?? "", 10);
|
|
57
|
+
return Number.isFinite(value) && value > 0 ? value : DEFAULT_MAX_CAPTURE_BYTES;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function discoveryEnv(): NodeJS.ProcessEnv {
|
|
61
|
+
return {
|
|
62
|
+
...process.env,
|
|
63
|
+
// Tool discovery/help is local-only and does not need real credentials.
|
|
64
|
+
FORGEJO_URL: DUMMY_FORGEJO_URL,
|
|
65
|
+
FORGEJO_ACCESS_TOKEN: DUMMY_FORGEJO_TOKEN,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function requireRuntimeEnv(): NodeJS.ProcessEnv {
|
|
70
|
+
const missing: string[] = [];
|
|
71
|
+
if (!process.env.FORGEJO_URL) missing.push("FORGEJO_URL");
|
|
72
|
+
if (!process.env.FORGEJO_ACCESS_TOKEN) missing.push("FORGEJO_ACCESS_TOKEN");
|
|
73
|
+
|
|
74
|
+
if (missing.length > 0) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`Forgejo MCP is not configured. Missing ${missing.join(", ")}. ` +
|
|
77
|
+
"Set FORGEJO_URL and FORGEJO_ACCESS_TOKEN in the environment before starting Pi.",
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return process.env;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function sanitizeText(text: string, token = process.env.FORGEJO_ACCESS_TOKEN): string {
|
|
85
|
+
if (!token || token.length < 4) return text;
|
|
86
|
+
return text.split(token).join("<FORGEJO_ACCESS_TOKEN>");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function truncateForError(text: string, max = 4_000): string {
|
|
90
|
+
const cleaned = sanitizeText(text.trim());
|
|
91
|
+
if (cleaned.length <= max) return cleaned;
|
|
92
|
+
return `${cleaned.slice(0, max)}\n...[truncated]`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function validateToolName(tool: string): string {
|
|
96
|
+
const trimmed = tool.trim();
|
|
97
|
+
if (!toolNamePattern.test(trimmed)) {
|
|
98
|
+
throw new Error(`Invalid forgejo-mcp tool name: ${tool}`);
|
|
99
|
+
}
|
|
100
|
+
return trimmed;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function runForgejo(
|
|
104
|
+
args: string[],
|
|
105
|
+
options: { env: NodeJS.ProcessEnv; signal?: AbortSignal },
|
|
106
|
+
): Promise<CommandResult> {
|
|
107
|
+
const command = getCommand();
|
|
108
|
+
const timeoutMs = getTimeoutMs();
|
|
109
|
+
const maxCaptureBytes = getMaxCaptureBytes();
|
|
110
|
+
|
|
111
|
+
return new Promise<CommandResult>((resolve, reject) => {
|
|
112
|
+
let stdoutBytes = 0;
|
|
113
|
+
let stderrBytes = 0;
|
|
114
|
+
let timedOut = false;
|
|
115
|
+
let exceededCapture = false;
|
|
116
|
+
let closed = false;
|
|
117
|
+
let killStarted = false;
|
|
118
|
+
const stdoutChunks: Buffer[] = [];
|
|
119
|
+
const stderrChunks: Buffer[] = [];
|
|
120
|
+
|
|
121
|
+
const child = spawn(command, args, {
|
|
122
|
+
env: options.env,
|
|
123
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const cleanupFns: Array<() => void> = [];
|
|
127
|
+
|
|
128
|
+
const kill = (reason: "timeout" | "abort" | "capture") => {
|
|
129
|
+
if (reason === "timeout") timedOut = true;
|
|
130
|
+
if (reason === "capture") exceededCapture = true;
|
|
131
|
+
if (killStarted) return;
|
|
132
|
+
killStarted = true;
|
|
133
|
+
if (!closed) child.kill("SIGTERM");
|
|
134
|
+
const killTimer = setTimeout(() => {
|
|
135
|
+
if (!closed) child.kill("SIGKILL");
|
|
136
|
+
}, 2_000);
|
|
137
|
+
cleanupFns.push(() => clearTimeout(killTimer));
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const timeout = setTimeout(() => kill("timeout"), timeoutMs);
|
|
141
|
+
cleanupFns.push(() => clearTimeout(timeout));
|
|
142
|
+
|
|
143
|
+
const onAbort = () => kill("abort");
|
|
144
|
+
if (options.signal) {
|
|
145
|
+
if (options.signal.aborted) onAbort();
|
|
146
|
+
else {
|
|
147
|
+
options.signal.addEventListener("abort", onAbort, { once: true });
|
|
148
|
+
cleanupFns.push(() => options.signal?.removeEventListener("abort", onAbort));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
153
|
+
stdoutBytes += chunk.length;
|
|
154
|
+
if (stdoutBytes + stderrBytes > maxCaptureBytes) {
|
|
155
|
+
kill("capture");
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
stdoutChunks.push(chunk);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
162
|
+
stderrBytes += chunk.length;
|
|
163
|
+
if (stdoutBytes + stderrBytes > maxCaptureBytes) {
|
|
164
|
+
kill("capture");
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
stderrChunks.push(chunk);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
child.on("error", (error) => {
|
|
171
|
+
for (const cleanup of cleanupFns) cleanup();
|
|
172
|
+
reject(error);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
child.on("close", (code, signal) => {
|
|
176
|
+
closed = true;
|
|
177
|
+
for (const cleanup of cleanupFns) cleanup();
|
|
178
|
+
|
|
179
|
+
const stdout = sanitizeText(Buffer.concat(stdoutChunks).toString("utf8"));
|
|
180
|
+
const stderr = sanitizeText(Buffer.concat(stderrChunks).toString("utf8"));
|
|
181
|
+
|
|
182
|
+
if (exceededCapture) {
|
|
183
|
+
reject(
|
|
184
|
+
new Error(`forgejo-mcp output exceeded FORGEJO_MCP_MAX_CAPTURE_BYTES (${formatSize(maxCaptureBytes)}).`),
|
|
185
|
+
);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
resolve({ stdout, stderr, code, signal, timedOut });
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function ensureSuccess(result: CommandResult, context: string): void {
|
|
195
|
+
if (result.code === 0 && !result.timedOut) return;
|
|
196
|
+
|
|
197
|
+
const timeoutNote = result.timedOut ? " timed out" : " failed";
|
|
198
|
+
const stderr = truncateForError(result.stderr);
|
|
199
|
+
const stdout = truncateForError(result.stdout);
|
|
200
|
+
const pieces = [`forgejo-mcp ${context}${timeoutNote}`];
|
|
201
|
+
if (result.code !== null) pieces.push(`exit code: ${result.code}`);
|
|
202
|
+
if (result.signal) pieces.push(`signal: ${result.signal}`);
|
|
203
|
+
if (stderr) pieces.push(`stderr:\n${stderr}`);
|
|
204
|
+
if (stdout) pieces.push(`stdout:\n${stdout}`);
|
|
205
|
+
throw new Error(pieces.join("\n\n"));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export async function formatOutput(rawOutput: string, stderr: string): Promise<FormattedOutput> {
|
|
209
|
+
const trimmed = rawOutput.trim();
|
|
210
|
+
let output = trimmed || "OK (no output)";
|
|
211
|
+
|
|
212
|
+
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
213
|
+
try {
|
|
214
|
+
output = JSON.stringify(JSON.parse(trimmed), null, 2);
|
|
215
|
+
} catch {
|
|
216
|
+
output = trimmed;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const truncation = truncateHead(output, {
|
|
221
|
+
maxLines: DEFAULT_MAX_LINES,
|
|
222
|
+
maxBytes: DEFAULT_MAX_BYTES,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const details: FormattedOutput["details"] = {
|
|
226
|
+
truncated: truncation.truncated,
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
let text = truncation.content;
|
|
230
|
+
|
|
231
|
+
if (truncation.truncated) {
|
|
232
|
+
const dir = await mkdtemp(join(tmpdir(), "pi-forgejo-mcp-"));
|
|
233
|
+
const file = join(dir, "output.txt");
|
|
234
|
+
await writeFile(file, output, "utf8");
|
|
235
|
+
|
|
236
|
+
details.truncation = truncation;
|
|
237
|
+
details.fullOutputPath = file;
|
|
238
|
+
|
|
239
|
+
text += `\n\n[Output truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`;
|
|
240
|
+
text += ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).`;
|
|
241
|
+
text += ` Full output saved to: ${file}]`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const cleanedStderr = truncateForError(stderr, 2_000);
|
|
245
|
+
if (cleanedStderr) details.stderr = cleanedStderr;
|
|
246
|
+
|
|
247
|
+
return { text, details };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
let toolsCache: ForgejoToolSummary[] | undefined;
|
|
251
|
+
|
|
252
|
+
export function clearToolsCache(): void {
|
|
253
|
+
toolsCache = undefined;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export async function listForgejoTools(signal?: AbortSignal): Promise<ForgejoToolSummary[]> {
|
|
257
|
+
if (toolsCache) return toolsCache;
|
|
258
|
+
const result = await runForgejo(["--cli", "list", "--output=json"], { env: discoveryEnv(), signal });
|
|
259
|
+
ensureSuccess(result, "tool list");
|
|
260
|
+
|
|
261
|
+
let parsed: unknown;
|
|
262
|
+
try {
|
|
263
|
+
parsed = JSON.parse(result.stdout);
|
|
264
|
+
} catch (error) {
|
|
265
|
+
throw new Error(`forgejo-mcp returned invalid tool list JSON: ${String(error)}`, { cause: error });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (!Array.isArray(parsed)) {
|
|
269
|
+
throw new Error("forgejo-mcp returned an unexpected tool list shape.");
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
toolsCache = parsed
|
|
273
|
+
.map((item) => item as Record<string, unknown>)
|
|
274
|
+
.filter((item) => typeof item.name === "string")
|
|
275
|
+
.map((item) => ({
|
|
276
|
+
name: String(item.name),
|
|
277
|
+
description: typeof item.description === "string" ? item.description : undefined,
|
|
278
|
+
domain: typeof item.domain === "string" ? item.domain : undefined,
|
|
279
|
+
}))
|
|
280
|
+
.sort((a, b) => `${a.domain ?? ""}/${a.name}`.localeCompare(`${b.domain ?? ""}/${b.name}`));
|
|
281
|
+
|
|
282
|
+
return toolsCache;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export async function callForgejoTool(
|
|
286
|
+
tool: string,
|
|
287
|
+
args: Record<string, unknown> | undefined,
|
|
288
|
+
signal: AbortSignal | undefined,
|
|
289
|
+
ctx: ExtensionContext,
|
|
290
|
+
) {
|
|
291
|
+
const validTool = validateToolName(tool);
|
|
292
|
+
await confirmMutationIfNeeded(ctx, validTool, args);
|
|
293
|
+
|
|
294
|
+
const result = await runForgejo(["--cli", validTool, "--args", JSON.stringify(args ?? {}), "--output=json"], {
|
|
295
|
+
env: requireRuntimeEnv(),
|
|
296
|
+
signal,
|
|
297
|
+
});
|
|
298
|
+
ensureSuccess(result, validTool);
|
|
299
|
+
|
|
300
|
+
const output = await formatOutput(result.stdout, result.stderr);
|
|
301
|
+
return {
|
|
302
|
+
content: [{ type: "text" as const, text: output.text }],
|
|
303
|
+
details: {
|
|
304
|
+
operation: validTool,
|
|
305
|
+
...output.details,
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
3
|
+
import {
|
|
4
|
+
callForgejoTool,
|
|
5
|
+
formatOutput,
|
|
6
|
+
getCommand,
|
|
7
|
+
listForgejoTools,
|
|
8
|
+
runForgejo,
|
|
9
|
+
discoveryEnv,
|
|
10
|
+
ensureSuccess,
|
|
11
|
+
truncateForError,
|
|
12
|
+
validateToolName,
|
|
13
|
+
} from "./cli.ts";
|
|
14
|
+
|
|
15
|
+
export default function forgejoMcpExtension(pi: ExtensionAPI) {
|
|
16
|
+
pi.registerTool({
|
|
17
|
+
name: "forgejo_mcp_status",
|
|
18
|
+
label: "Forgejo MCP Status",
|
|
19
|
+
description: "Check whether the forgejo-mcp CLI and required Forgejo environment variables are available.",
|
|
20
|
+
promptSnippet: "Check Forgejo MCP bridge configuration before using Forgejo tools.",
|
|
21
|
+
parameters: Type.Object({}),
|
|
22
|
+
async execute(_toolCallId, _params, signal) {
|
|
23
|
+
let cliAvailable = false;
|
|
24
|
+
let cliError: string | undefined;
|
|
25
|
+
let toolCount: number | undefined;
|
|
26
|
+
try {
|
|
27
|
+
const tools = await listForgejoTools(signal);
|
|
28
|
+
cliAvailable = true;
|
|
29
|
+
toolCount = tools.length;
|
|
30
|
+
} catch (error) {
|
|
31
|
+
cliError = error instanceof Error ? error.message : String(error);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const configured = Boolean(process.env.FORGEJO_URL && process.env.FORGEJO_ACCESS_TOKEN);
|
|
35
|
+
const lines = [
|
|
36
|
+
`forgejo-mcp command: ${getCommand()}`,
|
|
37
|
+
`CLI available: ${cliAvailable ? "yes" : "no"}`,
|
|
38
|
+
`FORGEJO_URL set: ${process.env.FORGEJO_URL ? "yes" : "no"}`,
|
|
39
|
+
`FORGEJO_ACCESS_TOKEN set: ${process.env.FORGEJO_ACCESS_TOKEN ? "yes" : "no"}`,
|
|
40
|
+
`Runtime configured: ${configured ? "yes" : "no"}`,
|
|
41
|
+
];
|
|
42
|
+
if (toolCount !== undefined) lines.push(`Discovered tools: ${toolCount}`);
|
|
43
|
+
if (cliError) lines.push(`CLI error: ${truncateForError(cliError)}`);
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
47
|
+
details: { command: getCommand(), cliAvailable, configured, toolCount, cliError },
|
|
48
|
+
};
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
pi.registerTool({
|
|
53
|
+
name: "forgejo_mcp_list_tools",
|
|
54
|
+
label: "Forgejo MCP List Tools",
|
|
55
|
+
description: "List operations exposed by the official forgejo-mcp CLI. Output is truncated to 2000 lines or 50KB.",
|
|
56
|
+
promptSnippet: "List Forgejo MCP operations before calling an unfamiliar Forgejo operation.",
|
|
57
|
+
promptGuidelines: [
|
|
58
|
+
"Use forgejo_mcp_list_tools to discover Forgejo operation names before using forgejo_mcp_call when the exact operation is uncertain.",
|
|
59
|
+
],
|
|
60
|
+
parameters: Type.Object({
|
|
61
|
+
domain: Type.Optional(
|
|
62
|
+
Type.String({
|
|
63
|
+
description: "Optional domain/category filter, such as repo, issue, pull, user, org, release, actions.",
|
|
64
|
+
}),
|
|
65
|
+
),
|
|
66
|
+
query: Type.Optional(
|
|
67
|
+
Type.String({ description: "Optional case-insensitive substring filter for operation names or descriptions." }),
|
|
68
|
+
),
|
|
69
|
+
}),
|
|
70
|
+
async execute(_toolCallId, params, signal) {
|
|
71
|
+
const domain = params.domain?.trim().toLowerCase();
|
|
72
|
+
const query = params.query?.trim().toLowerCase();
|
|
73
|
+
let tools = await listForgejoTools(signal);
|
|
74
|
+
|
|
75
|
+
if (domain) tools = tools.filter((tool) => tool.domain?.toLowerCase() === domain);
|
|
76
|
+
if (query) {
|
|
77
|
+
tools = tools.filter(
|
|
78
|
+
(tool) => tool.name.toLowerCase().includes(query) || (tool.description ?? "").toLowerCase().includes(query),
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const output = await formatOutput(JSON.stringify(tools, null, 2), "");
|
|
83
|
+
return {
|
|
84
|
+
content: [{ type: "text", text: output.text }],
|
|
85
|
+
details: { count: tools.length, domain, query, ...output.details },
|
|
86
|
+
};
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
pi.registerTool({
|
|
91
|
+
name: "forgejo_mcp_describe_tool",
|
|
92
|
+
label: "Forgejo MCP Describe Tool",
|
|
93
|
+
description: "Show parameters and help for one forgejo-mcp operation.",
|
|
94
|
+
promptSnippet: "Describe a Forgejo MCP operation's required and optional arguments.",
|
|
95
|
+
promptGuidelines: [
|
|
96
|
+
"Use forgejo_mcp_describe_tool before forgejo_mcp_call when you need the argument schema for a Forgejo operation.",
|
|
97
|
+
],
|
|
98
|
+
parameters: Type.Object({
|
|
99
|
+
tool: Type.String({ description: "forgejo-mcp operation name, for example list_repo_issues or create_issue." }),
|
|
100
|
+
}),
|
|
101
|
+
async execute(_toolCallId, params, signal) {
|
|
102
|
+
const tool = validateToolName(params.tool);
|
|
103
|
+
const result = await runForgejo(["--cli", tool, "--help"], { env: discoveryEnv(), signal });
|
|
104
|
+
ensureSuccess(result, `${tool} --help`);
|
|
105
|
+
const output = await formatOutput(result.stdout, result.stderr);
|
|
106
|
+
return {
|
|
107
|
+
content: [{ type: "text", text: output.text }],
|
|
108
|
+
details: { operation: tool, ...output.details },
|
|
109
|
+
};
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
pi.registerTool({
|
|
114
|
+
name: "forgejo_mcp_call",
|
|
115
|
+
label: "Forgejo MCP Call",
|
|
116
|
+
description:
|
|
117
|
+
"Invoke one operation from the official forgejo-mcp CLI. Requires FORGEJO_URL and FORGEJO_ACCESS_TOKEN in Pi's environment. Output is truncated to 2000 lines or 50KB.",
|
|
118
|
+
promptSnippet: "Call Forgejo operations via forgejo-mcp CLI using JSON arguments.",
|
|
119
|
+
promptGuidelines: [
|
|
120
|
+
"Use forgejo_mcp_call for Forgejo repository, issue, pull request, release, user, organization, workflow, and file operations.",
|
|
121
|
+
"Use forgejo_mcp_describe_tool before forgejo_mcp_call unless you already know the exact Forgejo operation arguments.",
|
|
122
|
+
"Do not put FORGEJO_ACCESS_TOKEN or other secrets in forgejo_mcp_call arguments; the extension reads credentials from the environment.",
|
|
123
|
+
],
|
|
124
|
+
parameters: Type.Object({
|
|
125
|
+
tool: Type.String({
|
|
126
|
+
description: "forgejo-mcp operation name, for example list_my_repos, list_repo_issues, or create_issue.",
|
|
127
|
+
}),
|
|
128
|
+
args: Type.Optional(
|
|
129
|
+
Type.Record(Type.String(), Type.Any({ description: "JSON argument value passed through to forgejo-mcp." }), {
|
|
130
|
+
description: "JSON arguments for the operation. Use {} when the operation takes no arguments.",
|
|
131
|
+
}),
|
|
132
|
+
),
|
|
133
|
+
}),
|
|
134
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
135
|
+
return callForgejoTool(params.tool, params.args as Record<string, unknown> | undefined, signal, ctx);
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
const mutatingOperationPattern =
|
|
4
|
+
/^(add_|approve_|cancel_|create_|delete_|dismiss_|dispatch_|edit_|fork_|mark_|merge_|remove_|request_|reset_|start_|stop_|submit_|sync_|transfer_|update_|issue_state_change|pull_request_state_change)/;
|
|
5
|
+
const destructiveOperationPattern = /^(delete_|remove_|merge_|transfer_|cancel_|dismiss_|reset_)/;
|
|
6
|
+
|
|
7
|
+
export function truthy(value: string | undefined): boolean {
|
|
8
|
+
return value === "1" || value === "true" || value === "yes" || value === "on";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function falsy(value: string | undefined): boolean {
|
|
12
|
+
return value === "0" || value === "false" || value === "no" || value === "off";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function isMutatingOperation(tool: string): boolean {
|
|
16
|
+
return mutatingOperationPattern.test(tool);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function isDestructiveOperation(tool: string): boolean {
|
|
20
|
+
return destructiveOperationPattern.test(tool);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function summarizeTarget(args: Record<string, unknown> | undefined): string {
|
|
24
|
+
if (!args) return "";
|
|
25
|
+
const owner = typeof args.owner === "string" ? args.owner : undefined;
|
|
26
|
+
const repo = typeof args.repo === "string" ? args.repo : undefined;
|
|
27
|
+
const index = typeof args.index === "number" || typeof args.index === "string" ? `#${args.index}` : undefined;
|
|
28
|
+
|
|
29
|
+
const parts: string[] = [];
|
|
30
|
+
if (owner && repo) parts.push(`${owner}/${repo}`);
|
|
31
|
+
else if (repo) parts.push(repo);
|
|
32
|
+
else if (owner) parts.push(owner);
|
|
33
|
+
if (index) parts.push(index);
|
|
34
|
+
|
|
35
|
+
return parts.length > 0 ? ` (${parts.join(" ")})` : "";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function confirmMutationIfNeeded(
|
|
39
|
+
ctx: ExtensionContext,
|
|
40
|
+
tool: string,
|
|
41
|
+
args: Record<string, unknown> | undefined,
|
|
42
|
+
): Promise<void> {
|
|
43
|
+
if (!isMutatingOperation(tool)) return;
|
|
44
|
+
|
|
45
|
+
if (ctx.hasUI && !falsy(process.env.FORGEJO_MCP_CONFIRM_MUTATIONS)) {
|
|
46
|
+
const severity = isDestructiveOperation(tool) ? "destructive Forgejo operation" : "Forgejo write operation";
|
|
47
|
+
const ok = await ctx.ui.confirm("Confirm Forgejo operation", `Allow ${severity}: ${tool}${summarizeTarget(args)}?`);
|
|
48
|
+
if (!ok) throw new Error(`Cancelled Forgejo operation: ${tool}`);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!truthy(process.env.FORGEJO_MCP_ALLOW_MUTATIONS)) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`Refusing to run mutating Forgejo operation ${tool} without UI confirmation. ` +
|
|
55
|
+
"Set FORGEJO_MCP_ALLOW_MUTATIONS=true to allow this in non-interactive mode.",
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./forgejo-mcp/index.ts";
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-forgejo-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pi extension that exposes Forgejo via the official forgejo-mcp CLI.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Ozzy",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://codeberg.org/ozzy92/pi-forgejo-mcp.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://codeberg.org/ozzy92/pi-forgejo-mcp",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://codeberg.org/ozzy92/pi-forgejo-mcp/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"pi-package",
|
|
18
|
+
"pi-extension",
|
|
19
|
+
"forgejo",
|
|
20
|
+
"mcp",
|
|
21
|
+
"model-context-protocol",
|
|
22
|
+
"codeberg"
|
|
23
|
+
],
|
|
24
|
+
"files": [
|
|
25
|
+
"extensions",
|
|
26
|
+
"README.md",
|
|
27
|
+
"LICENSE",
|
|
28
|
+
"SECURITY.md",
|
|
29
|
+
"CONTRIBUTING.md",
|
|
30
|
+
"CHANGELOG.md"
|
|
31
|
+
],
|
|
32
|
+
"pi": {
|
|
33
|
+
"extensions": [
|
|
34
|
+
"./extensions/forgejo-mcp/index.ts"
|
|
35
|
+
]
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"typecheck": "tsc --noEmit",
|
|
39
|
+
"lint": "eslint .",
|
|
40
|
+
"format": "prettier --check . --ignore-unknown",
|
|
41
|
+
"format:write": "prettier --write . --ignore-unknown",
|
|
42
|
+
"test": "vitest run",
|
|
43
|
+
"check": "npm run typecheck && npm run lint && npm run format && npm run test"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"@earendil-works/pi-ai": "*",
|
|
47
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@earendil-works/pi-ai": "^0.77.0",
|
|
51
|
+
"@earendil-works/pi-coding-agent": "^0.77.0",
|
|
52
|
+
"@eslint/js": "^10.0.1",
|
|
53
|
+
"@types/node": "^24.0.0",
|
|
54
|
+
"eslint": "^10.4.0",
|
|
55
|
+
"globals": "^17.6.0",
|
|
56
|
+
"prettier": "^3.8.3",
|
|
57
|
+
"typescript": "^5.9.0",
|
|
58
|
+
"typescript-eslint": "^8.60.0",
|
|
59
|
+
"vitest": "^4.1.7"
|
|
60
|
+
},
|
|
61
|
+
"engines": {
|
|
62
|
+
"node": ">=22.19.0"
|
|
63
|
+
}
|
|
64
|
+
}
|