@lunchbox-labs/opencode-tool-inspector 0.0.0-stage → 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/dist/tui.js ADDED
@@ -0,0 +1,413 @@
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
+ };
410
+ export default {
411
+ id: "tool-inspector",
412
+ tui: ToolInspectorTui,
413
+ };
package/package.json CHANGED
@@ -1,6 +1,62 @@
1
1
  {
2
2
  "name": "@lunchbox-labs/opencode-tool-inspector",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Inspect the tools available to OpenCode agents from the TUI",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/lunchbox-labs/opencode-tool-inspector.git"
10
+ },
11
+ "engines": {
12
+ "node": ">=22.18.0"
13
+ },
14
+ "exports": {
15
+ "./tui": {
16
+ "types": "./dist/tui.d.ts",
17
+ "default": "./dist/tui.js"
18
+ }
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "scripts": {
26
+ "build": "tsc -p tsconfig.build.json",
27
+ "typecheck": "tsc -p tsconfig.json",
28
+ "test": "node --experimental-strip-types --test tests/*.test.ts tests/*.test.mjs",
29
+ "lint": "eslint . --max-warnings 0",
30
+ "format": "prettier --write src tests scripts package.json tsconfig*.json *.config.mjs",
31
+ "format:check": "prettier --check src tests scripts package.json tsconfig*.json *.config.mjs",
32
+ "check": "npm run check:static && npm test",
33
+ "prepack": "npm run build",
34
+ "test:package": "npm run build && node --experimental-strip-types --test tests/package.integration.ts",
35
+ "check:static": "concurrently --names typecheck,lint,format --kill-others-on-fail \"npm run typecheck\" \"npm run lint\" \"npm run format:check\"",
36
+ "prebuild": "node --input-type=module -e \"import { rm } from 'node:fs/promises'; await rm('dist', { recursive: true, force: true });\""
37
+ },
38
+ "devDependencies": {
39
+ "@eslint-community/eslint-plugin-eslint-comments": "4.8.1",
40
+ "@eslint/js": "10.0.1",
41
+ "@opencode-ai/plugin": "1.19.5",
42
+ "@opencode-ai/sdk": "1.19.5",
43
+ "@opentui/core": "0.4.5",
44
+ "@opentui/keymap": "0.4.5",
45
+ "@opentui/solid": "0.4.5",
46
+ "@types/node": "24.19.2",
47
+ "concurrently": "10.0.6",
48
+ "eslint": "10.14.0",
49
+ "eslint-config-prettier": "10.1.8",
50
+ "eslint-plugin-regexp": "3.3.3",
51
+ "globals": "17.14.0",
52
+ "prettier": "3.9.10",
53
+ "solid-js": "1.9.12",
54
+ "typescript": "6.0.3",
55
+ "typescript-eslint": "8.72.1",
56
+ "yaml": "2.9.1"
57
+ },
58
+ "publishConfig": {
59
+ "access": "public",
60
+ "registry": "https://registry.npmjs.org"
61
+ }
62
+ }