@helloleo/plugins 0.1.2 → 0.1.4
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/dist/index.js +237 -494
- package/package.json +1 -1
- package/src/index.ts +5 -4
- package/src/tools/inspect-http.ts +258 -0
- package/src/utils/try-catch.ts +22 -0
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
export {
|
|
2
|
-
export { ListPlansPlugin } from "./tools/list-plans.ts";
|
|
3
|
-
export { ModifyPlanPlugin } from "./tools/modify-plan.ts";
|
|
1
|
+
export { InspectHttpPlugin } from "./tools/inspect-http.ts";
|
|
4
2
|
export { RemovePlugin } from "./tools/remove.ts";
|
|
5
|
-
export { ScaffoldPlugin } from "./tools/scaffold.ts";
|
|
3
|
+
// export { ScaffoldPlugin } from "./tools/scaffold.ts";
|
|
4
|
+
// export { CreatePlanPlugin } from "./tools/create-plan.ts";
|
|
5
|
+
// export { ListPlansPlugin } from "./tools/list-plans.ts";
|
|
6
|
+
// export { ModifyPlanPlugin } from "./tools/modify-plan.ts";
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode-ai/plugin";
|
|
2
|
+
import { tool } from "@opencode-ai/plugin";
|
|
3
|
+
|
|
4
|
+
const z = tool.schema;
|
|
5
|
+
|
|
6
|
+
const ALLOWED_EXACT_HOSTS = new Set(["api.app.helloleo.dev"]);
|
|
7
|
+
const ALLOWED_HOST_SUFFIXES = [
|
|
8
|
+
".augustin-d60.workers.dev",
|
|
9
|
+
".live.helloleo.dev",
|
|
10
|
+
] as const;
|
|
11
|
+
|
|
12
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
13
|
+
const MAX_TIMEOUT_MS = 30_000;
|
|
14
|
+
const MAX_RESPONSE_BYTES = 1_000_000;
|
|
15
|
+
|
|
16
|
+
const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const;
|
|
17
|
+
const METHODS_AUTO_JSON = new Set(["POST", "PUT", "PATCH"]);
|
|
18
|
+
|
|
19
|
+
// tool() is an identity function; parse args explicitly so .default() applies.
|
|
20
|
+
const argsShape = {
|
|
21
|
+
url: z
|
|
22
|
+
.string()
|
|
23
|
+
.describe(
|
|
24
|
+
"Fully-qualified URL to request. Must be on the HelloLeo whitelist " +
|
|
25
|
+
"(api.app.helloleo.dev, *.augustin-d60.workers.dev, *.live.helloleo.dev).",
|
|
26
|
+
),
|
|
27
|
+
method: z
|
|
28
|
+
.enum(METHODS)
|
|
29
|
+
.default("GET")
|
|
30
|
+
.describe(`HTTP method (${METHODS.join(", ")}). Defaults to GET.`),
|
|
31
|
+
body: z
|
|
32
|
+
.string()
|
|
33
|
+
.optional()
|
|
34
|
+
.describe(
|
|
35
|
+
"Request body as a string. Not valid for GET. Sent with " +
|
|
36
|
+
"Content-Type: application/json on POST/PUT/PATCH unless a " +
|
|
37
|
+
"different Content-Type is supplied via headers.",
|
|
38
|
+
),
|
|
39
|
+
headers: z
|
|
40
|
+
.record(z.string(), z.string())
|
|
41
|
+
.default({})
|
|
42
|
+
.describe(
|
|
43
|
+
"Optional additional request headers as key/value pairs (e.g. Authorization).",
|
|
44
|
+
),
|
|
45
|
+
timeout: z
|
|
46
|
+
.number()
|
|
47
|
+
.max(MAX_TIMEOUT_MS)
|
|
48
|
+
.default(DEFAULT_TIMEOUT_MS)
|
|
49
|
+
.describe(
|
|
50
|
+
`Optional timeout in milliseconds (max ${MAX_TIMEOUT_MS}, default ${DEFAULT_TIMEOUT_MS}).`,
|
|
51
|
+
),
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const argsSchema = z.object(argsShape);
|
|
55
|
+
|
|
56
|
+
export const inspectHttp = tool({
|
|
57
|
+
description:
|
|
58
|
+
"Send an HTTP request to a HelloLeo endpoint and return the status code, " +
|
|
59
|
+
"response headers, and response body. Use this to inspect the live behaviour " +
|
|
60
|
+
"of HelloLeo workers, the production API, or preview deployments so you can " +
|
|
61
|
+
"self-debug instead of operating blind. " +
|
|
62
|
+
"Only URLs on the HelloLeo whitelist are allowed: " +
|
|
63
|
+
"api.app.helloleo.dev, *.augustin-d60.workers.dev, *.live.helloleo.dev. " +
|
|
64
|
+
"Supports GET, POST, PUT, PATCH and DELETE (with an optional JSON body).",
|
|
65
|
+
args: argsShape,
|
|
66
|
+
async execute(rawArgs, context) {
|
|
67
|
+
const args = argsSchema.parse(rawArgs);
|
|
68
|
+
const url = parseTargetUrl(args.url);
|
|
69
|
+
|
|
70
|
+
if (args.method === "GET" && args.body !== undefined) {
|
|
71
|
+
throw new Error("GET requests cannot have a body");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
await context.ask({
|
|
75
|
+
permission: "inspect_http",
|
|
76
|
+
patterns: [url.toString()],
|
|
77
|
+
always: [`${url.protocol}//${url.hostname}/*`],
|
|
78
|
+
metadata: {
|
|
79
|
+
url: url.toString(),
|
|
80
|
+
method: args.method,
|
|
81
|
+
hasBody: args.body !== undefined,
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const timeoutMs = Math.max(1, Math.min(args.timeout, MAX_TIMEOUT_MS));
|
|
86
|
+
|
|
87
|
+
const requestHeaders: Record<string, string> = {
|
|
88
|
+
Accept: "application/json, text/plain, */*",
|
|
89
|
+
"User-Agent": "helloleo-plugins-inspect-http/0.1",
|
|
90
|
+
Origin: "https://app.helloleo.dev",
|
|
91
|
+
...args.headers,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
if (
|
|
95
|
+
METHODS_AUTO_JSON.has(args.method) &&
|
|
96
|
+
args.body !== undefined &&
|
|
97
|
+
!hasHeader(requestHeaders, "content-type")
|
|
98
|
+
) {
|
|
99
|
+
requestHeaders["Content-Type"] = "application/json";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const signal = AbortSignal.any([
|
|
103
|
+
context.abort,
|
|
104
|
+
AbortSignal.timeout(timeoutMs),
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
let response: Response;
|
|
108
|
+
try {
|
|
109
|
+
response = await fetch(url, {
|
|
110
|
+
method: args.method,
|
|
111
|
+
headers: requestHeaders,
|
|
112
|
+
body: args.method === "GET" ? undefined : args.body,
|
|
113
|
+
signal,
|
|
114
|
+
redirect: "follow",
|
|
115
|
+
});
|
|
116
|
+
} catch (err) {
|
|
117
|
+
if (err instanceof DOMException && err.name === "TimeoutError") {
|
|
118
|
+
throw new Error(`Request timed out after ${timeoutMs}ms`);
|
|
119
|
+
}
|
|
120
|
+
if (err instanceof DOMException && err.name === "AbortError") {
|
|
121
|
+
throw new Error("Request aborted");
|
|
122
|
+
}
|
|
123
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
124
|
+
throw new Error(`Request failed: ${message}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const { text, truncated } = await readBody(response);
|
|
128
|
+
const contentType = response.headers.get("content-type");
|
|
129
|
+
const formattedBody = formatBody(text, contentType);
|
|
130
|
+
|
|
131
|
+
const redirected = response.url && response.url !== url.toString();
|
|
132
|
+
|
|
133
|
+
const lines: string[] = [`${args.method} ${url.toString()}`];
|
|
134
|
+
if (redirected) {
|
|
135
|
+
lines.push(`Final URL: ${response.url}`);
|
|
136
|
+
}
|
|
137
|
+
lines.push(
|
|
138
|
+
`Status: ${response.status}${response.statusText ? ` ${response.statusText}` : ""}`,
|
|
139
|
+
"",
|
|
140
|
+
"Response headers:",
|
|
141
|
+
formatHeaders(response.headers) || "(none)",
|
|
142
|
+
"",
|
|
143
|
+
"Response body:",
|
|
144
|
+
formattedBody.length > 0 ? formattedBody : "(empty)",
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
if (truncated) {
|
|
148
|
+
lines.push("", `(body truncated at ${MAX_RESPONSE_BYTES} bytes)`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return lines.join("\n");
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
function parseTargetUrl(raw: string): URL {
|
|
156
|
+
let url: URL;
|
|
157
|
+
try {
|
|
158
|
+
url = new URL(raw);
|
|
159
|
+
} catch {
|
|
160
|
+
throw new Error(`Invalid URL: ${raw}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
164
|
+
throw new Error(
|
|
165
|
+
`URL must use http or https scheme (got "${url.protocol}")`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (url.username || url.password) {
|
|
170
|
+
throw new Error("URL must not contain embedded credentials");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (!isAllowedHost(url.hostname)) {
|
|
174
|
+
throw new Error(
|
|
175
|
+
`Host "${url.hostname}" is not on the HelloLeo whitelist. Allowed: ${allowedHostsHuman()}`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return url;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function allowedHostsHuman(): string {
|
|
183
|
+
return [
|
|
184
|
+
...ALLOWED_EXACT_HOSTS,
|
|
185
|
+
...ALLOWED_HOST_SUFFIXES.map((s) => `*${s}`),
|
|
186
|
+
].join(", ");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function isAllowedHost(hostname: string): boolean {
|
|
190
|
+
const host = hostname.toLowerCase().replace(/\.$/, "");
|
|
191
|
+
if (ALLOWED_EXACT_HOSTS.has(host)) return true;
|
|
192
|
+
for (const suffix of ALLOWED_HOST_SUFFIXES) {
|
|
193
|
+
if (host.endsWith(suffix) && host.length > suffix.length) return true;
|
|
194
|
+
}
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function hasHeader(headers: Record<string, string>, name: string): boolean {
|
|
199
|
+
const target = name.toLowerCase();
|
|
200
|
+
for (const key of Object.keys(headers)) {
|
|
201
|
+
if (key.toLowerCase() === target) return true;
|
|
202
|
+
}
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function formatHeaders(headers: Headers): string {
|
|
207
|
+
const entries: string[] = [];
|
|
208
|
+
headers.forEach((value, key) => {
|
|
209
|
+
entries.push(`${key}: ${value}`);
|
|
210
|
+
});
|
|
211
|
+
return entries.sort().join("\n");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function formatBody(raw: string, contentType: string | null): string {
|
|
215
|
+
const mime = contentType?.split(";")[0]?.trim().toLowerCase() ?? "";
|
|
216
|
+
if (mime.includes("application/json") || mime.endsWith("+json")) {
|
|
217
|
+
try {
|
|
218
|
+
return JSON.stringify(JSON.parse(raw), null, 2);
|
|
219
|
+
} catch {}
|
|
220
|
+
}
|
|
221
|
+
return raw;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function readBody(response: Response): Promise<{
|
|
225
|
+
text: string;
|
|
226
|
+
truncated: boolean;
|
|
227
|
+
}> {
|
|
228
|
+
if (!response.body) return { text: "", truncated: false };
|
|
229
|
+
|
|
230
|
+
const reader = response.body.getReader();
|
|
231
|
+
const decoder = new TextDecoder();
|
|
232
|
+
let text = "";
|
|
233
|
+
let total = 0;
|
|
234
|
+
let truncated = false;
|
|
235
|
+
|
|
236
|
+
while (true) {
|
|
237
|
+
const { done, value } = await reader.read();
|
|
238
|
+
if (done) break;
|
|
239
|
+
if (!value) continue;
|
|
240
|
+
|
|
241
|
+
total += value.byteLength;
|
|
242
|
+
if (total > MAX_RESPONSE_BYTES) {
|
|
243
|
+
truncated = true;
|
|
244
|
+
await reader.cancel();
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
text += decoder.decode(value, { stream: true });
|
|
248
|
+
}
|
|
249
|
+
text += decoder.decode();
|
|
250
|
+
|
|
251
|
+
return { text, truncated };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export const InspectHttpPlugin: Plugin = async () => {
|
|
255
|
+
return {
|
|
256
|
+
tool: { inspect_http: inspectHttp },
|
|
257
|
+
};
|
|
258
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
type Success<T> = {
|
|
2
|
+
data: T;
|
|
3
|
+
error: null;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
type Failure<E> = {
|
|
7
|
+
data: null;
|
|
8
|
+
error: E;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
type Result<T, E = Error> = Success<T> | Failure<E>;
|
|
12
|
+
|
|
13
|
+
export async function tryCatch<T, E = Error>(
|
|
14
|
+
promise: Promise<T>,
|
|
15
|
+
): Promise<Result<T, E>> {
|
|
16
|
+
try {
|
|
17
|
+
const data = await promise;
|
|
18
|
+
return { data, error: null };
|
|
19
|
+
} catch (error) {
|
|
20
|
+
return { data: null, error: error as E };
|
|
21
|
+
}
|
|
22
|
+
}
|