@lunchbox-labs/opencode-tool-inspector 0.1.0 → 0.2.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/README.md +14 -2
- package/dist/agent-text.d.ts +4 -0
- package/dist/agent-text.js +85 -0
- package/dist/agents.d.ts +79 -0
- package/dist/agents.js +244 -0
- package/dist/detail-viewer.d.ts +6 -0
- package/dist/detail-viewer.js +123 -0
- package/dist/schema-text.d.ts +10 -0
- package/dist/schema-text.js +429 -0
- package/dist/tool-detail.d.ts +17 -0
- package/dist/tool-detail.js +41 -0
- package/dist/tools.d.ts +34 -0
- package/dist/tools.js +119 -0
- package/dist/tui.js +409 -1
- package/package.json +1 -1
package/dist/tui.js
CHANGED
|
@@ -1,4 +1,412 @@
|
|
|
1
|
-
|
|
1
|
+
import { resolveToolAgentAccess, } from "./agents.js";
|
|
2
|
+
import { createDetailViewer } from "./detail-viewer.js";
|
|
3
|
+
import { linesText } from "./schema-text.js";
|
|
4
|
+
import { toolDetail } from "./tool-detail.js";
|
|
5
|
+
import { chosenModelLabel, configuredModel, descriptionSummary, inputCount, providerDefaultModel, sessionModel, toolsFromIds, toolsFromList, } from "./tools.js";
|
|
6
|
+
const toastTitle = "Tool Inspector";
|
|
7
|
+
/** The plain-text fallback can't scroll, so long sections are cut short. */
|
|
8
|
+
const plainTextSectionLines = 20;
|
|
9
|
+
function errorText(error) {
|
|
10
|
+
if (error instanceof Error) {
|
|
11
|
+
return error.message;
|
|
12
|
+
}
|
|
13
|
+
if (typeof error === "object" && error !== null) {
|
|
14
|
+
const record = error;
|
|
15
|
+
if (typeof record.message === "string") {
|
|
16
|
+
return record.message;
|
|
17
|
+
}
|
|
18
|
+
if (typeof record.data?.message === "string") {
|
|
19
|
+
return record.data.message;
|
|
20
|
+
}
|
|
21
|
+
return JSON.stringify(error);
|
|
22
|
+
}
|
|
23
|
+
return String(error);
|
|
24
|
+
}
|
|
25
|
+
/** Names of connected MCP servers, whose tools OpenCode's tool list leaves out. */
|
|
26
|
+
function connectedMcpServers(api) {
|
|
27
|
+
try {
|
|
28
|
+
return api.state
|
|
29
|
+
.mcp()
|
|
30
|
+
.filter((server) => server.status === "connected")
|
|
31
|
+
.map((server) => server.name);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function mcpNotice(servers) {
|
|
38
|
+
if (servers.length === 0) {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
const names = servers.join(", ");
|
|
42
|
+
return `Tools from MCP servers (${names}) aren't listed, because OpenCode only adds them when it runs a prompt.`;
|
|
43
|
+
}
|
|
44
|
+
function currentSessionId(api) {
|
|
45
|
+
const route = api.route.current;
|
|
46
|
+
return route.name === "session" && typeof route.params?.sessionID === "string"
|
|
47
|
+
? route.params.sessionID
|
|
48
|
+
: undefined;
|
|
49
|
+
}
|
|
50
|
+
async function chooseModel(api, signal) {
|
|
51
|
+
const sessionId = currentSessionId(api);
|
|
52
|
+
const fromSession = sessionId === undefined
|
|
53
|
+
? undefined
|
|
54
|
+
: sessionModel(api.state.session.messages(sessionId));
|
|
55
|
+
if (fromSession !== undefined) {
|
|
56
|
+
return { ...fromSession, source: "session" };
|
|
57
|
+
}
|
|
58
|
+
const connectedProviderIds = api.state.provider.map((provider) => provider.id);
|
|
59
|
+
const configured = configuredModel(api.state.config.model);
|
|
60
|
+
// OpenCode skips a configured model whose provider isn't connected.
|
|
61
|
+
if (configured !== undefined &&
|
|
62
|
+
(connectedProviderIds.length === 0 ||
|
|
63
|
+
connectedProviderIds.includes(configured.providerID))) {
|
|
64
|
+
return { ...configured, source: "configured" };
|
|
65
|
+
}
|
|
66
|
+
const response = await api.client.config.providers({ directory: api.state.path.directory }, { signal });
|
|
67
|
+
if (response.error !== undefined) {
|
|
68
|
+
throw new Error(errorText(response.error));
|
|
69
|
+
}
|
|
70
|
+
const fallback = providerDefaultModel(connectedProviderIds, response.data.default);
|
|
71
|
+
return fallback === undefined
|
|
72
|
+
? undefined
|
|
73
|
+
: { ...fallback, source: "provider default" };
|
|
74
|
+
}
|
|
75
|
+
async function readAgents(api, signal) {
|
|
76
|
+
// Guards hosts whose client predates the agents route.
|
|
77
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the host supplies the client at runtime.
|
|
78
|
+
if (typeof api.client.app?.agents !== "function") {
|
|
79
|
+
throw new Error("this OpenCode doesn't list agents");
|
|
80
|
+
}
|
|
81
|
+
const response = await api.client.app.agents({ directory: api.state.path.directory }, { signal });
|
|
82
|
+
if (response.error !== undefined) {
|
|
83
|
+
throw new Error(errorText(response.error));
|
|
84
|
+
}
|
|
85
|
+
const agents = response.data;
|
|
86
|
+
if (!Array.isArray(agents)) {
|
|
87
|
+
throw new Error("the response wasn't an agent list");
|
|
88
|
+
}
|
|
89
|
+
// resolveToolAgentAccess skips entries it can't read.
|
|
90
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- entries are validated field by field downstream.
|
|
91
|
+
return agents;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The permission rules of the current session's top-level session, which OpenCode applies after
|
|
95
|
+
* each agent's own rules. Sessions usually have none. When a parent isn't loaded, the topmost
|
|
96
|
+
* session found is used, since a child session carries copies of its parent's deny rules.
|
|
97
|
+
*/
|
|
98
|
+
function sessionPermission(api) {
|
|
99
|
+
const sessionId = currentSessionId(api);
|
|
100
|
+
if (sessionId === undefined) {
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
let session = api.state.session.get(sessionId);
|
|
105
|
+
const seen = new Set();
|
|
106
|
+
while (session?.parentID !== undefined && !seen.has(session.id)) {
|
|
107
|
+
seen.add(session.id);
|
|
108
|
+
const parent = api.state.session.get(session.parentID);
|
|
109
|
+
if (parent === undefined) {
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
session = parent;
|
|
113
|
+
}
|
|
114
|
+
return session?.permission ?? [];
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function primaryTools(api) {
|
|
121
|
+
try {
|
|
122
|
+
return api.state.config.experimental?.primary_tools;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function accessByTool(api, tools, agents) {
|
|
129
|
+
const resolved = resolveToolAgentAccess(tools.map((tool) => tool.id), agents, {
|
|
130
|
+
// The module reads both options defensively and reports unreadable ones as unknown.
|
|
131
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- validated in resolveToolAgentAccess.
|
|
132
|
+
primaryTools: primaryTools(api),
|
|
133
|
+
sessionPermission: sessionPermission(api),
|
|
134
|
+
});
|
|
135
|
+
return new Map(resolved.map((access) => [access.tool, access]));
|
|
136
|
+
}
|
|
137
|
+
async function readToolList(api, signal) {
|
|
138
|
+
const directory = api.state.path.directory;
|
|
139
|
+
let modelFailure;
|
|
140
|
+
const model = await chooseModel(api, signal).catch((error) => {
|
|
141
|
+
modelFailure = errorText(error);
|
|
142
|
+
return undefined;
|
|
143
|
+
});
|
|
144
|
+
let listFailure;
|
|
145
|
+
if (model !== undefined) {
|
|
146
|
+
try {
|
|
147
|
+
const response = await api.client.tool.list({ directory, provider: model.providerID, model: model.modelID }, { signal });
|
|
148
|
+
if (response.error === undefined) {
|
|
149
|
+
const tools = toolsFromList(response.data);
|
|
150
|
+
if (tools !== undefined) {
|
|
151
|
+
return { tools, model, notice: undefined };
|
|
152
|
+
}
|
|
153
|
+
listFailure = "the response wasn't a tool list";
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
listFailure = errorText(response.error);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
listFailure = errorText(error);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const response = await api.client.tool.ids({ directory }, { signal });
|
|
164
|
+
const tools = response.error === undefined ? toolsFromIds(response.data) : undefined;
|
|
165
|
+
if (tools === undefined) {
|
|
166
|
+
throw new Error(listFailure ??
|
|
167
|
+
modelFailure ??
|
|
168
|
+
(response.error === undefined
|
|
169
|
+
? "OpenCode did not return a readable tool list."
|
|
170
|
+
: errorText(response.error)));
|
|
171
|
+
}
|
|
172
|
+
const namesOnly = model === undefined
|
|
173
|
+
? modelFailure === undefined
|
|
174
|
+
? "No model is selected, so this lists every registered tool without details. Pick a model to see what OpenCode offers it."
|
|
175
|
+
: `Could not work out the current model (${modelFailure}), so this lists tool names only.`
|
|
176
|
+
: `OpenCode did not return tool details for ${chosenModelLabel(model)} (${listFailure ?? "no reason given"}), so this lists tool names only.`;
|
|
177
|
+
return { tools, model: undefined, notice: namesOnly };
|
|
178
|
+
}
|
|
179
|
+
async function readCatalog(api, signal) {
|
|
180
|
+
let agentsFailure;
|
|
181
|
+
const agentsRead = readAgents(api, signal).catch((error) => {
|
|
182
|
+
agentsFailure = errorText(error);
|
|
183
|
+
return undefined;
|
|
184
|
+
});
|
|
185
|
+
const list = await readToolList(api, signal);
|
|
186
|
+
const agents = await agentsRead;
|
|
187
|
+
let access;
|
|
188
|
+
if (agents !== undefined) {
|
|
189
|
+
try {
|
|
190
|
+
access = accessByTool(api, list.tools, agents);
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
agentsFailure = errorText(error);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
...list,
|
|
198
|
+
access,
|
|
199
|
+
agentsNotice: access === undefined
|
|
200
|
+
? `Could not read OpenCode's agents (${agentsFailure ?? "no reason given"}), so tool details don't show which agents can use each tool.`
|
|
201
|
+
: undefined,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
function inputCountLabel(tool) {
|
|
205
|
+
const count = inputCount(tool.parameters);
|
|
206
|
+
if (count === undefined) {
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
return count === 1 ? "1 input" : `${count} inputs`;
|
|
210
|
+
}
|
|
211
|
+
function registerToolBrowser(api) {
|
|
212
|
+
const toast = (variant, message) => {
|
|
213
|
+
api.ui.toast({
|
|
214
|
+
variant,
|
|
215
|
+
title: toastTitle,
|
|
216
|
+
message,
|
|
217
|
+
duration: variant === "warning" ? 10_000 : 5_000,
|
|
218
|
+
});
|
|
219
|
+
};
|
|
220
|
+
let viewer;
|
|
221
|
+
const detailViewer = () => {
|
|
222
|
+
viewer ??= createDetailViewer(api).catch((error) => {
|
|
223
|
+
toast("warning", `Showing tool details as plain text: ${errorText(error)}`);
|
|
224
|
+
return undefined;
|
|
225
|
+
});
|
|
226
|
+
return viewer;
|
|
227
|
+
};
|
|
228
|
+
/** Each displayed dialog gets a revision, so stale close callbacks and late reads are ignored. */
|
|
229
|
+
let revision = 0;
|
|
230
|
+
let showing = false;
|
|
231
|
+
/** The read behind the loading dialog, aborted once anything replaces that dialog. */
|
|
232
|
+
let activeRead;
|
|
233
|
+
const close = () => {
|
|
234
|
+
revision++;
|
|
235
|
+
showing = false;
|
|
236
|
+
activeRead?.abort();
|
|
237
|
+
activeRead = undefined;
|
|
238
|
+
api.ui.dialog.clear();
|
|
239
|
+
};
|
|
240
|
+
const viewContext = () => {
|
|
241
|
+
const directory = api.state.path.directory;
|
|
242
|
+
const sessionId = currentSessionId(api);
|
|
243
|
+
const routeName = api.route.current.name;
|
|
244
|
+
return () => !api.lifecycle.signal.aborted &&
|
|
245
|
+
directory === api.state.path.directory &&
|
|
246
|
+
routeName === api.route.current.name &&
|
|
247
|
+
sessionId === currentSessionId(api);
|
|
248
|
+
};
|
|
249
|
+
const display = (render, size, onClosed) => {
|
|
250
|
+
const active = ++revision;
|
|
251
|
+
showing = true;
|
|
252
|
+
activeRead?.abort();
|
|
253
|
+
activeRead = undefined;
|
|
254
|
+
api.ui.dialog.replace(render, () => {
|
|
255
|
+
if (active !== revision) {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
revision++;
|
|
259
|
+
showing = false;
|
|
260
|
+
onClosed?.();
|
|
261
|
+
});
|
|
262
|
+
api.ui.dialog.setSize(size);
|
|
263
|
+
return active;
|
|
264
|
+
};
|
|
265
|
+
const views = {
|
|
266
|
+
async detail(catalog, tool) {
|
|
267
|
+
const listRevision = revision;
|
|
268
|
+
const sameView = viewContext();
|
|
269
|
+
const detail = toolDetail(tool, catalog.model === undefined
|
|
270
|
+
? undefined
|
|
271
|
+
: chosenModelLabel(catalog.model), catalog.access?.get(tool.id));
|
|
272
|
+
const render = await detailViewer();
|
|
273
|
+
if (listRevision !== revision || !sameView()) {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
display(render === undefined
|
|
277
|
+
? () => api.ui.DialogAlert({
|
|
278
|
+
title: detail.title,
|
|
279
|
+
message: detail.sections
|
|
280
|
+
.map((section) => `${section.heading}\n${linesText(section.lines.slice(0, plainTextSectionLines))}${section.lines.length > plainTextSectionLines ? "\n…" : ""}`)
|
|
281
|
+
.join("\n\n"),
|
|
282
|
+
})
|
|
283
|
+
: () => render(detail), "xlarge", () => {
|
|
284
|
+
const closedRevision = revision;
|
|
285
|
+
// The host clears the dialog after this callback; return to the list afterwards.
|
|
286
|
+
queueMicrotask(() => {
|
|
287
|
+
if (closedRevision === revision &&
|
|
288
|
+
!api.ui.dialog.open &&
|
|
289
|
+
sameView()) {
|
|
290
|
+
views.list(catalog, tool.id);
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
},
|
|
295
|
+
list(catalog, current) {
|
|
296
|
+
const sameView = viewContext();
|
|
297
|
+
display(() => api.ui.DialogSelect({
|
|
298
|
+
title: catalog.model === undefined
|
|
299
|
+
? "Tools"
|
|
300
|
+
: `Tools for ${chosenModelLabel(catalog.model)}`,
|
|
301
|
+
placeholder: "Search tools",
|
|
302
|
+
current,
|
|
303
|
+
options: catalog.tools.map((tool) => {
|
|
304
|
+
const summary = descriptionSummary(tool.description);
|
|
305
|
+
const footer = inputCountLabel(tool);
|
|
306
|
+
return {
|
|
307
|
+
title: tool.id,
|
|
308
|
+
value: tool.id,
|
|
309
|
+
...(summary === "" ? {} : { description: summary }),
|
|
310
|
+
...(footer === undefined ? {} : { footer }),
|
|
311
|
+
};
|
|
312
|
+
}),
|
|
313
|
+
onSelect: (option) => {
|
|
314
|
+
const tool = catalog.tools.find((item) => item.id === option.value);
|
|
315
|
+
if (!sameView()) {
|
|
316
|
+
close();
|
|
317
|
+
toast("info", "The session changed, so run /tools again.");
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (tool !== undefined) {
|
|
321
|
+
views.detail(catalog, tool).catch((error) => {
|
|
322
|
+
toast("warning", `Could not show ${tool.id}: ${errorText(error)}`);
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
},
|
|
326
|
+
}), "large");
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
const open = async () => {
|
|
330
|
+
const sameView = viewContext();
|
|
331
|
+
const controller = new AbortController();
|
|
332
|
+
const abort = () => controller.abort();
|
|
333
|
+
api.lifecycle.signal.addEventListener("abort", abort, { once: true });
|
|
334
|
+
const loading = display(() => api.ui.DialogAlert({
|
|
335
|
+
title: "Loading tools",
|
|
336
|
+
message: "Reading the tools OpenCode offers…",
|
|
337
|
+
}), "medium", abort);
|
|
338
|
+
activeRead = controller;
|
|
339
|
+
// Load the viewer alongside the list so opening a tool doesn't wait for it.
|
|
340
|
+
detailViewer().catch(() => undefined);
|
|
341
|
+
try {
|
|
342
|
+
const catalog = await readCatalog(api, controller.signal);
|
|
343
|
+
if (loading !== revision) {
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (!sameView()) {
|
|
347
|
+
close();
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
if (catalog.tools.length === 0) {
|
|
351
|
+
close();
|
|
352
|
+
toast("info", "OpenCode reported no tools.");
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const notices = [
|
|
356
|
+
catalog.notice,
|
|
357
|
+
catalog.agentsNotice,
|
|
358
|
+
mcpNotice(connectedMcpServers(api)),
|
|
359
|
+
].filter((notice) => notice !== undefined);
|
|
360
|
+
if (notices.length > 0) {
|
|
361
|
+
toast(catalog.notice === undefined && catalog.agentsNotice === undefined
|
|
362
|
+
? "info"
|
|
363
|
+
: "warning", notices.join(" "));
|
|
364
|
+
}
|
|
365
|
+
views.list(catalog, undefined);
|
|
366
|
+
}
|
|
367
|
+
catch (error) {
|
|
368
|
+
if (loading === revision) {
|
|
369
|
+
close();
|
|
370
|
+
if (sameView()) {
|
|
371
|
+
toast("warning", `Could not read tools from OpenCode: ${errorText(error)}`);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
finally {
|
|
376
|
+
api.lifecycle.signal.removeEventListener("abort", abort);
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
const unregister = api.keymap.registerLayer({
|
|
380
|
+
commands: [
|
|
381
|
+
{
|
|
382
|
+
name: "tool-inspector.browse",
|
|
383
|
+
title: "Browse tools",
|
|
384
|
+
category: "Tools",
|
|
385
|
+
namespace: "palette",
|
|
386
|
+
slashName: "tools",
|
|
387
|
+
// Native command dispatch clears its picker after invoking the handler.
|
|
388
|
+
run: () => Promise.resolve()
|
|
389
|
+
.then(open)
|
|
390
|
+
.catch((error) => {
|
|
391
|
+
toast("warning", `Could not open tools: ${errorText(error)}`);
|
|
392
|
+
}),
|
|
393
|
+
},
|
|
394
|
+
],
|
|
395
|
+
});
|
|
396
|
+
const stopWatching = api.event.on("server.instance.disposed", (event) => {
|
|
397
|
+
if (event.properties.directory === api.state.path.directory && showing) {
|
|
398
|
+
close();
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
api.lifecycle.onDispose(() => {
|
|
402
|
+
unregister();
|
|
403
|
+
stopWatching();
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
const ToolInspectorTui = (api) => {
|
|
407
|
+
registerToolBrowser(api);
|
|
408
|
+
return Promise.resolve();
|
|
409
|
+
};
|
|
2
410
|
export default {
|
|
3
411
|
id: "tool-inspector",
|
|
4
412
|
tui: ToolInspectorTui,
|