@trim21/personal-pi-extensions 0.1.634 → 0.1.635
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/package.json +1 -1
- package/src/gh/base.ts +13 -141
- package/src/gh/tools/watch-run.ts +2 -1
- package/src/lib/gh-process.ts +157 -0
- package/src/lib/github.ts +4 -35
package/package.json
CHANGED
package/src/gh/base.ts
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared infrastructure of the GitHub read-only tools:
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Shared infrastructure of the GitHub read-only tools: result shaping, the
|
|
3
|
+
* octokit-backed client, and the checks-wait pipeline (running a `gh` process
|
|
4
|
+
* itself lives in `lib/gh-process.ts`, which also owns the proxy env shared by
|
|
5
|
+
* the subprocess and octokit). Each tool lives in `tools/<tool-name>.ts`;
|
|
6
|
+
* anything used by more than one tool belongs here.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
|
-
import { spawn } from "node:child_process";
|
|
9
9
|
import { existsSync } from "node:fs";
|
|
10
10
|
import { delimiter, join } from "node:path";
|
|
11
11
|
|
|
12
12
|
import { Type } from "typebox";
|
|
13
13
|
import { Value } from "typebox/value";
|
|
14
14
|
|
|
15
|
+
import { type GhResult, httpProxy, runGh } from "../lib/gh-process.js";
|
|
15
16
|
import {
|
|
16
17
|
type ActionJob,
|
|
17
18
|
type CheckRun,
|
|
@@ -19,17 +20,11 @@ import {
|
|
|
19
20
|
createGithubChecks,
|
|
20
21
|
createGithubSearch,
|
|
21
22
|
type GithubChecksClient,
|
|
23
|
+
type GithubClientOptions,
|
|
22
24
|
type GithubSearch,
|
|
23
25
|
renderHits,
|
|
24
26
|
} from "../lib/github.js";
|
|
25
27
|
import { type ToolPendant } from "../lib/pendant.js";
|
|
26
|
-
import { createHttpProxy } from "../lib/proxy.js";
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* 代理配置(~/.pi/agent/proxy.json,回退到 HTTP(S)_PROXY 环境变量)在本模块内共享:
|
|
30
|
-
* `gh` 子进程与 octokit 请求都从这里取,配置只在首次使用时读一次。
|
|
31
|
-
*/
|
|
32
|
-
export const httpProxy = createHttpProxy();
|
|
33
28
|
|
|
34
29
|
/** A tool result: what the model sees plus the structured details payload. */
|
|
35
30
|
export interface ToolResult {
|
|
@@ -46,18 +41,6 @@ export interface ToolCall<Params> {
|
|
|
46
41
|
onUpdate?: (update: ToolResult) => void;
|
|
47
42
|
}
|
|
48
43
|
|
|
49
|
-
export interface GhResult {
|
|
50
|
-
stdout: string;
|
|
51
|
-
stderr: string;
|
|
52
|
-
code: number;
|
|
53
|
-
killed: boolean;
|
|
54
|
-
combined: string;
|
|
55
|
-
/** Why the process was killed, when `killed` is true. */
|
|
56
|
-
reason?: "timeout" | "abort";
|
|
57
|
-
/** When the process could not be started at all (e.g. `gh` not found in PATH). */
|
|
58
|
-
spawnError?: string;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
44
|
/**
|
|
62
45
|
* Check whether the `gh` CLI is on the system, scanning PATH like
|
|
63
46
|
* `findDefaultBwrap`. The extension registers no tools when `gh` is missing, so
|
|
@@ -78,120 +61,6 @@ export function isGhAvailable(): boolean {
|
|
|
78
61
|
return false;
|
|
79
62
|
}
|
|
80
63
|
|
|
81
|
-
export function runGh(
|
|
82
|
-
args: string[],
|
|
83
|
-
ctx: {
|
|
84
|
-
cwd?: string;
|
|
85
|
-
signal?: AbortSignal;
|
|
86
|
-
timeout?: number;
|
|
87
|
-
/** 追加到子进程环境变量(覆盖进程环境与代理配置),供测试或调用方定制。 */
|
|
88
|
-
env?: NodeJS.ProcessEnv;
|
|
89
|
-
},
|
|
90
|
-
): Promise<GhResult> {
|
|
91
|
-
return new Promise((resolve) => {
|
|
92
|
-
const proc = spawn("gh", args, {
|
|
93
|
-
cwd: ctx.cwd,
|
|
94
|
-
shell: false,
|
|
95
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
96
|
-
// gh 是 Go 程序,只认环境变量形式的代理配置;ctx.env 最后合并,调用方可覆盖。
|
|
97
|
-
env: { ...process.env, ...httpProxy.env, ...ctx.env, GH_PAGER: "cat" },
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
let stdout = "";
|
|
101
|
-
let stderr = "";
|
|
102
|
-
const combined: string[] = [];
|
|
103
|
-
let killed = false;
|
|
104
|
-
let killReason: "timeout" | "abort" | undefined;
|
|
105
|
-
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
106
|
-
let onAbort: (() => void) | undefined;
|
|
107
|
-
|
|
108
|
-
const killProcess = (reason: "timeout" | "abort") => {
|
|
109
|
-
if (killed) {
|
|
110
|
-
return;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
killed = true;
|
|
114
|
-
killReason = reason;
|
|
115
|
-
proc.kill("SIGTERM");
|
|
116
|
-
setTimeout(() => {
|
|
117
|
-
if (!proc.killed) {
|
|
118
|
-
proc.kill("SIGKILL");
|
|
119
|
-
}
|
|
120
|
-
}, 5000);
|
|
121
|
-
};
|
|
122
|
-
|
|
123
|
-
if (ctx.signal) {
|
|
124
|
-
onAbort = () => killProcess("abort");
|
|
125
|
-
if (ctx.signal.aborted) {
|
|
126
|
-
killProcess("abort");
|
|
127
|
-
} else {
|
|
128
|
-
ctx.signal.addEventListener("abort", onAbort, { once: true });
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// Default timeout: 10 minutes. Long operations like downloading a CI job's
|
|
133
|
-
// full log routinely take well over 30s, so a short default would kill them
|
|
134
|
-
// mid-transfer; combined with `code ?? 0` that would silently cache a
|
|
135
|
-
// truncated log as success. A killed process must never look successful.
|
|
136
|
-
const timeout = ctx.timeout ?? 600_000;
|
|
137
|
-
if (timeout > 0) {
|
|
138
|
-
timeoutId = setTimeout(() => killProcess("timeout"), timeout);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
proc.stdout.on("data", (data: Buffer) => {
|
|
142
|
-
const text = data.toString();
|
|
143
|
-
stdout += text;
|
|
144
|
-
combined.push(text);
|
|
145
|
-
});
|
|
146
|
-
proc.stderr.on("data", (data: Buffer) => {
|
|
147
|
-
const text = data.toString();
|
|
148
|
-
stderr += text;
|
|
149
|
-
combined.push(text);
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
proc.on("close", (code) => {
|
|
153
|
-
if (timeoutId) {
|
|
154
|
-
clearTimeout(timeoutId);
|
|
155
|
-
}
|
|
156
|
-
if (onAbort && ctx.signal) {
|
|
157
|
-
ctx.signal.removeEventListener("abort", onAbort);
|
|
158
|
-
}
|
|
159
|
-
resolve({
|
|
160
|
-
stdout,
|
|
161
|
-
stderr,
|
|
162
|
-
// When killed by a signal the close event's code is null — including
|
|
163
|
-
// kills we did not initiate. Report failure instead of pretending it
|
|
164
|
-
// succeeded. -1 is a sentinel for "did not exit normally" — distinct
|
|
165
|
-
// from a real gh failure exit code (1), which is always in 0-255.
|
|
166
|
-
code: code ?? -1,
|
|
167
|
-
killed,
|
|
168
|
-
combined: combined.join(""),
|
|
169
|
-
reason: killReason,
|
|
170
|
-
});
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
proc.on("error", (err: Error) => {
|
|
174
|
-
if (timeoutId) {
|
|
175
|
-
clearTimeout(timeoutId);
|
|
176
|
-
}
|
|
177
|
-
if (onAbort && ctx.signal) {
|
|
178
|
-
ctx.signal.removeEventListener("abort", onAbort);
|
|
179
|
-
}
|
|
180
|
-
// spawn 失败(如 gh 不在 PATH → ENOENT、cwd 不存在)时进程从未启动,
|
|
181
|
-
// 没有任何 stdout/stderr;把底层错误带上,否则会退化成无信息的 "exit code 1"。
|
|
182
|
-
resolve({
|
|
183
|
-
stdout,
|
|
184
|
-
stderr,
|
|
185
|
-
code: 1,
|
|
186
|
-
killed,
|
|
187
|
-
combined: combined.join(""),
|
|
188
|
-
reason: killReason,
|
|
189
|
-
spawnError: err.message,
|
|
190
|
-
});
|
|
191
|
-
});
|
|
192
|
-
});
|
|
193
|
-
}
|
|
194
|
-
|
|
195
64
|
/**
|
|
196
65
|
* Error thrown by `ghExec` when the `gh` invocation exits non-zero.
|
|
197
66
|
* The message carries the toolcall input (JSON) wrapped in `<input>` markers,
|
|
@@ -474,10 +343,13 @@ export class GhClient {
|
|
|
474
343
|
readonly search: GithubSearch;
|
|
475
344
|
readonly checks: GithubChecksClient;
|
|
476
345
|
|
|
477
|
-
constructor(
|
|
346
|
+
constructor(
|
|
347
|
+
fetchImpl: typeof globalThis.fetch = httpProxy.fetch,
|
|
348
|
+
options: Pick<GithubClientOptions, "token"> = {},
|
|
349
|
+
) {
|
|
478
350
|
this.fetch = fetchImpl;
|
|
479
|
-
this.search = createGithubSearch({ fetch: fetchImpl });
|
|
480
|
-
this.checks = createGithubChecks({ fetch: fetchImpl });
|
|
351
|
+
this.search = createGithubSearch({ fetch: fetchImpl, ...options });
|
|
352
|
+
this.checks = createGithubChecks({ fetch: fetchImpl, ...options });
|
|
481
353
|
}
|
|
482
354
|
}
|
|
483
355
|
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { runGh } from "../../lib/gh-process.js";
|
|
5
|
+
import { type GhClient, repoArgs, subtitlePendant } from "../base.js";
|
|
5
6
|
|
|
6
7
|
export function addWatchRunTool(_gh: GhClient, pi: ExtensionAPI) {
|
|
7
8
|
pi.registerTool({
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `gh` subprocess adapter: start a `gh` process, collect its output, apply the
|
|
3
|
+
* shared timeout / kill semantics, and expose the proxy layer the subprocess
|
|
4
|
+
* inherits. It lives in `lib/` so both the gh tool layer (`src/gh/`) and the
|
|
5
|
+
* octokit clients (`lib/github.ts`) can depend on it without a cycle.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
|
|
10
|
+
import { createHttpProxy } from "./proxy.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 代理配置(~/.pi/agent/proxy.json,回退到 HTTP(S)_PROXY 环境变量)在本模块内共享:
|
|
14
|
+
* `gh` 子进程与 octokit 请求都从这里取,配置只在首次使用时读一次。
|
|
15
|
+
*/
|
|
16
|
+
export const httpProxy = createHttpProxy();
|
|
17
|
+
|
|
18
|
+
export interface GhResult {
|
|
19
|
+
stdout: string;
|
|
20
|
+
stderr: string;
|
|
21
|
+
code: number;
|
|
22
|
+
killed: boolean;
|
|
23
|
+
combined: string;
|
|
24
|
+
/** Why the process was killed, when `killed` is true. */
|
|
25
|
+
reason?: "timeout" | "abort";
|
|
26
|
+
/** When the process could not be started at all (e.g. `gh` not found in PATH). */
|
|
27
|
+
spawnError?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface GhRunContext {
|
|
31
|
+
cwd?: string;
|
|
32
|
+
signal?: AbortSignal;
|
|
33
|
+
timeout?: number;
|
|
34
|
+
/** 追加到子进程环境变量(覆盖进程环境与代理配置),供测试或调用方定制。 */
|
|
35
|
+
env?: NodeJS.ProcessEnv;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function runGh(args: string[], ctx: GhRunContext): Promise<GhResult> {
|
|
39
|
+
return new Promise((resolve) => {
|
|
40
|
+
const proc = spawn("gh", args, {
|
|
41
|
+
cwd: ctx.cwd,
|
|
42
|
+
shell: false,
|
|
43
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
44
|
+
// gh 是 Go 程序,只认环境变量形式的代理配置;ctx.env 最后合并,调用方可覆盖。
|
|
45
|
+
env: { ...process.env, ...httpProxy.env, ...ctx.env, GH_PAGER: "cat" },
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
let stdout = "";
|
|
49
|
+
let stderr = "";
|
|
50
|
+
const combined: string[] = [];
|
|
51
|
+
let killed = false;
|
|
52
|
+
let killReason: "timeout" | "abort" | undefined;
|
|
53
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
54
|
+
let onAbort: (() => void) | undefined;
|
|
55
|
+
|
|
56
|
+
const killProcess = (reason: "timeout" | "abort") => {
|
|
57
|
+
if (killed) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
killed = true;
|
|
62
|
+
killReason = reason;
|
|
63
|
+
proc.kill("SIGTERM");
|
|
64
|
+
setTimeout(() => {
|
|
65
|
+
if (!proc.killed) {
|
|
66
|
+
proc.kill("SIGKILL");
|
|
67
|
+
}
|
|
68
|
+
}, 5000);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
if (ctx.signal) {
|
|
72
|
+
onAbort = () => killProcess("abort");
|
|
73
|
+
if (ctx.signal.aborted) {
|
|
74
|
+
killProcess("abort");
|
|
75
|
+
} else {
|
|
76
|
+
ctx.signal.addEventListener("abort", onAbort, { once: true });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Default timeout: 10 minutes. Long operations like downloading a CI job's
|
|
81
|
+
// full log routinely take well over 30s, so a short default would kill them
|
|
82
|
+
// mid-transfer; combined with `code ?? 0` that would silently cache a
|
|
83
|
+
// truncated log as success. A killed process must never look successful.
|
|
84
|
+
const timeout = ctx.timeout ?? 600_000;
|
|
85
|
+
if (timeout > 0) {
|
|
86
|
+
timeoutId = setTimeout(() => killProcess("timeout"), timeout);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
proc.stdout.on("data", (data: Buffer) => {
|
|
90
|
+
const text = data.toString();
|
|
91
|
+
stdout += text;
|
|
92
|
+
combined.push(text);
|
|
93
|
+
});
|
|
94
|
+
proc.stderr.on("data", (data: Buffer) => {
|
|
95
|
+
const text = data.toString();
|
|
96
|
+
stderr += text;
|
|
97
|
+
combined.push(text);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
proc.on("close", (code) => {
|
|
101
|
+
if (timeoutId) {
|
|
102
|
+
clearTimeout(timeoutId);
|
|
103
|
+
}
|
|
104
|
+
if (onAbort && ctx.signal) {
|
|
105
|
+
ctx.signal.removeEventListener("abort", onAbort);
|
|
106
|
+
}
|
|
107
|
+
resolve({
|
|
108
|
+
stdout,
|
|
109
|
+
stderr,
|
|
110
|
+
// When killed by a signal the close event's code is null — including
|
|
111
|
+
// kills we did not initiate. Report failure instead of pretending it
|
|
112
|
+
// succeeded. -1 is a sentinel for "did not exit normally" — distinct
|
|
113
|
+
// from a real gh failure exit code (1), which is always in 0-255.
|
|
114
|
+
code: code ?? -1,
|
|
115
|
+
killed,
|
|
116
|
+
combined: combined.join(""),
|
|
117
|
+
reason: killReason,
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
proc.on("error", (err: Error) => {
|
|
122
|
+
if (timeoutId) {
|
|
123
|
+
clearTimeout(timeoutId);
|
|
124
|
+
}
|
|
125
|
+
if (onAbort && ctx.signal) {
|
|
126
|
+
ctx.signal.removeEventListener("abort", onAbort);
|
|
127
|
+
}
|
|
128
|
+
// spawn 失败(如 gh 不在 PATH → ENOENT、cwd 不存在)时进程从未启动,
|
|
129
|
+
// 没有任何 stdout/stderr;把底层错误带上,否则会退化成无信息的 "exit code 1"。
|
|
130
|
+
resolve({
|
|
131
|
+
stdout,
|
|
132
|
+
stderr,
|
|
133
|
+
code: 1,
|
|
134
|
+
killed,
|
|
135
|
+
combined: combined.join(""),
|
|
136
|
+
reason: killReason,
|
|
137
|
+
spawnError: err.message,
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Read the logged-in token from the system `gh` CLI (`gh auth token`). */
|
|
144
|
+
export async function ghAuthToken(): Promise<string> {
|
|
145
|
+
const result = await runGh(["auth", "token"], { timeout: 10_000 });
|
|
146
|
+
if (result.spawnError) {
|
|
147
|
+
throw new Error(`failed to start gh: ${result.spawnError}`);
|
|
148
|
+
}
|
|
149
|
+
const token = result.stdout.trim();
|
|
150
|
+
if (token && result.code === 0) {
|
|
151
|
+
return token;
|
|
152
|
+
}
|
|
153
|
+
throw new Error(
|
|
154
|
+
result.stderr.trim() ||
|
|
155
|
+
`gh auth token exited with code ${result.code} — run "gh auth login" first`,
|
|
156
|
+
);
|
|
157
|
+
}
|
package/src/lib/github.ts
CHANGED
|
@@ -8,11 +8,10 @@
|
|
|
8
8
|
* rendered state is derived from `pull_request.merged_at`.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { spawn } from "node:child_process";
|
|
12
|
-
|
|
13
11
|
import { Octokit } from "octokit";
|
|
14
12
|
import { type Static, Type } from "typebox";
|
|
15
13
|
|
|
14
|
+
import { ghAuthToken } from "./gh-process.js";
|
|
16
15
|
import { parseWithSchema } from "./parse-with-schema.js";
|
|
17
16
|
|
|
18
17
|
export type SearchKind = "issue" | "pr";
|
|
@@ -218,38 +217,6 @@ function normalize(raw: SearchItem): SearchHit {
|
|
|
218
217
|
};
|
|
219
218
|
}
|
|
220
219
|
|
|
221
|
-
function ghAuthToken(): Promise<string> {
|
|
222
|
-
return new Promise((resolve, reject) => {
|
|
223
|
-
const proc = spawn("gh", ["auth", "token"], { stdio: ["ignore", "pipe", "pipe"] });
|
|
224
|
-
let stdout = "";
|
|
225
|
-
let stderr = "";
|
|
226
|
-
const timer = setTimeout(() => proc.kill("SIGTERM"), 10_000);
|
|
227
|
-
proc.stdout.on("data", (d: Buffer) => {
|
|
228
|
-
stdout += String(d);
|
|
229
|
-
});
|
|
230
|
-
proc.stderr.on("data", (d: Buffer) => {
|
|
231
|
-
stderr += String(d);
|
|
232
|
-
});
|
|
233
|
-
proc.on("error", (err) => {
|
|
234
|
-
clearTimeout(timer);
|
|
235
|
-
reject(new Error(`failed to start gh: ${err.message}`));
|
|
236
|
-
});
|
|
237
|
-
proc.on("close", (code) => {
|
|
238
|
-
clearTimeout(timer);
|
|
239
|
-
const token = stdout.trim();
|
|
240
|
-
if (code === 0 && token) {
|
|
241
|
-
resolve(token);
|
|
242
|
-
} else {
|
|
243
|
-
reject(
|
|
244
|
-
new Error(
|
|
245
|
-
stderr.trim() || `gh auth token exited with code ${code} — run "gh auth login" first`,
|
|
246
|
-
),
|
|
247
|
-
);
|
|
248
|
-
}
|
|
249
|
-
});
|
|
250
|
-
});
|
|
251
|
-
}
|
|
252
|
-
|
|
253
220
|
/**
|
|
254
221
|
* Error thrown when the GitHub search API rejects the request. Carries the
|
|
255
222
|
* original toolcall params so the model can see the exact input.
|
|
@@ -291,6 +258,8 @@ export interface GithubClientOptions {
|
|
|
291
258
|
* proxy dispatcher attached. Defaults to the global fetch.
|
|
292
259
|
*/
|
|
293
260
|
fetch?: typeof globalThis.fetch;
|
|
261
|
+
/** gh token provider;缺省读系统 `gh auth token`(见 lib/gh-process.ts)。 */
|
|
262
|
+
token?: () => Promise<string>;
|
|
294
263
|
}
|
|
295
264
|
|
|
296
265
|
/**
|
|
@@ -304,7 +273,7 @@ function createGithubApi(options: GithubClientOptions = {}): GithubApi {
|
|
|
304
273
|
|
|
305
274
|
async function getClient(): Promise<Octokit> {
|
|
306
275
|
client ??= new Octokit({
|
|
307
|
-
auth: await ghAuthToken(),
|
|
276
|
+
auth: await (options.token ?? ghAuthToken)(),
|
|
308
277
|
...(options.fetch && { request: { fetch: options.fetch } }),
|
|
309
278
|
});
|
|
310
279
|
return client;
|