From b88650d7266c4e797e274b86ca10a0c57a01b679 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Wed, 22 Jul 2026 07:43:12 +0000 Subject: [PATCH 1/2] feat(inspect): list hub commands and let the inspector execute them Adds a Commands tab to the inspector, backed by two new RPC functions: devframes:plugin:inspect:list-commands and devframes:plugin:inspect:execute-command. Both duck-type ctx.commands (the hub's commands host) so the plugin keeps no build/runtime dependency on @devframes/hub and degrades gracefully outside a hub (empty list / thrown diagnostic). --- plugins/inspect/package.json | 1 + plugins/inspect/src/client/index.ts | 2 +- plugins/inspect/src/diagnostics.ts | 5 + .../src/rpc/functions/_hub-commands.ts | 61 ++++++++++ .../src/rpc/functions/execute-command.ts | 46 ++++++++ .../src/rpc/functions/list-commands.ts | 33 ++++++ plugins/inspect/src/rpc/index.ts | 4 + plugins/inspect/src/spa/App.vue | 5 +- .../inspect/src/spa/components/CommandRow.vue | 111 ++++++++++++++++++ .../src/spa/components/CommandsSmart.vue | 47 ++++++++ .../spa/components/CommandsView.stories.ts | 92 +++++++++++++++ .../src/spa/components/CommandsView.vue | 92 +++++++++++++++ plugins/inspect/src/spa/style.css | 41 +++++++ plugins/inspect/src/types.ts | 21 ++++ plugins/inspect/test/_utils.ts | 34 +++++- plugins/inspect/test/dev-server.test.ts | 108 ++++++++++++++++- plugins/inspect/test/static-build.test.ts | 4 +- pnpm-lock.yaml | 3 + 18 files changed, 701 insertions(+), 9 deletions(-) create mode 100644 plugins/inspect/src/rpc/functions/_hub-commands.ts create mode 100644 plugins/inspect/src/rpc/functions/execute-command.ts create mode 100644 plugins/inspect/src/rpc/functions/list-commands.ts create mode 100644 plugins/inspect/src/spa/components/CommandRow.vue create mode 100644 plugins/inspect/src/spa/components/CommandsSmart.vue create mode 100644 plugins/inspect/src/spa/components/CommandsView.stories.ts create mode 100644 plugins/inspect/src/spa/components/CommandsView.vue diff --git a/plugins/inspect/package.json b/plugins/inspect/package.json index bd53d485..fabe1271 100644 --- a/plugins/inspect/package.json +++ b/plugins/inspect/package.json @@ -62,6 +62,7 @@ }, "devDependencies": { "@antfu/design": "catalog:frontend", + "@devframes/hub": "workspace:*", "@iconify-json/ph": "catalog:frontend", "@storybook/addon-docs": "catalog:storybook", "@storybook/vue3-vite": "catalog:storybook", diff --git a/plugins/inspect/src/client/index.ts b/plugins/inspect/src/client/index.ts index e253b1b5..4dd578a1 100644 --- a/plugins/inspect/src/client/index.ts +++ b/plugins/inspect/src/client/index.ts @@ -2,7 +2,7 @@ import type { DevframeConnectionStatus, DevframeRpcClient, DevframeRpcClientOpti import { connectDevframe } from 'devframe/client' export type { DevframeConnectionStatus, DevframeRpcClient } -export type { AgentManifest, InvokeResult, RpcFunctionAgentInfo, RpcFunctionInfo } from '../types' +export type { AgentManifest, DevframeInspectCommandInfo, InvokeResult, RpcFunctionAgentInfo, RpcFunctionInfo } from '../types' /** * Connect to the inspector's devframe backend. A thin, typed wrapper diff --git a/plugins/inspect/src/diagnostics.ts b/plugins/inspect/src/diagnostics.ts index 84ce9b76..f5dacb38 100644 --- a/plugins/inspect/src/diagnostics.ts +++ b/plugins/inspect/src/diagnostics.ts @@ -19,5 +19,10 @@ export const diagnostics = defineDiagnostics({ `Refusing to invoke "${p.name}" — only read-only "query" and "static" functions are invokable from the inspector, but this one is "${p.type}".`, fix: 'The inspector deliberately blocks `action`/`event` functions to avoid triggering side effects. Invoke those through their own UI instead.', }, + DP_INSPECT_0003: { + why: (p: { id: string }) => + `Cannot execute command "${p.id}" — this connection has no hub commands host.`, + fix: 'Commands are a `@devframes/hub` feature. Mount this devframe inside a hub that registers commands via `ctx.commands.register()`, or check `devframes:plugin:inspect:list-commands` (returns an empty list outside a hub).', + }, }, }) diff --git a/plugins/inspect/src/rpc/functions/_hub-commands.ts b/plugins/inspect/src/rpc/functions/_hub-commands.ts new file mode 100644 index 00000000..0f962c4c --- /dev/null +++ b/plugins/inspect/src/rpc/functions/_hub-commands.ts @@ -0,0 +1,61 @@ +import type { DevframeInspectCommandInfo } from '../../types' + +/** + * Minimal shape of the hub's commands host (`DevframeHubContext.commands`, + * `@devframes/hub/src/types/commands.ts`) that this plugin reads from. + * Declared locally and accessed by duck-typing so the inspector keeps no + * build/runtime dependency on `@devframes/hub` and degrades to an empty + * list / a thrown diagnostic outside a hub. + */ +export interface HubCommandLike { + id: string + title: string + description?: string + icon?: string | { light: string, dark: string } + category?: string + handler?: (...args: any[]) => any + children?: HubCommandLike[] +} + +export interface HubCommandsHostLike { + commands: Map + execute: (id: string, ...args: any[]) => Promise +} + +function looksLikeHubCommandsHost(value: unknown): value is HubCommandsHostLike { + return !!value + && typeof value === 'object' + && (value as { commands?: unknown }).commands instanceof Map + && typeof (value as { execute?: unknown }).execute === 'function' +} + +/** + * Resolve `ctx.commands` when this connection is mounted inside a hub, or + * `undefined` on a plain devframe connection (CLI / Vite / build without a + * hub). Structural rather than an `instanceof`/import check, matching the + * pattern established in `plugins/terminals/src/node/manager.ts`. + */ +export function resolveHubCommands(ctx: object): HubCommandsHostLike | undefined { + const commands = (ctx as { commands?: unknown }).commands + return looksLikeHubCommandsHost(commands) ? commands : undefined +} + +/** + * Project a raw hub command (still carrying its `handler`) into the + * serializable shape the inspector sends over RPC, recursing into + * children. `hasHandler` survives the projection even though the hub's + * own `commands.list()` strips `handler` entirely before it's callable + * from here — reading the raw command straight off `host.commands` + * preserves that flag. + */ +export function projectCommand(cmd: HubCommandLike): DevframeInspectCommandInfo { + return { + id: cmd.id, + title: cmd.title, + description: cmd.description, + icon: cmd.icon, + category: cmd.category, + hasHandler: typeof cmd.handler === 'function', + children: cmd.children?.map(projectCommand), + } +} diff --git a/plugins/inspect/src/rpc/functions/execute-command.ts b/plugins/inspect/src/rpc/functions/execute-command.ts new file mode 100644 index 00000000..f00bdff5 --- /dev/null +++ b/plugins/inspect/src/rpc/functions/execute-command.ts @@ -0,0 +1,46 @@ +import type { InvokeResult } from '../../types' +import { diagnostics } from '../../diagnostics' +import { defineInspectRpc } from './_define' +import { resolveHubCommands } from './_hub-commands' + +/** + * Execute a hub command by id and return a result envelope, mirroring + * `devframes:plugin:inspect:invoke`. Unlike that function, this one is not + * gated to read-only types — a command is, by construction, something a + * user explicitly runs (unlike an arbitrary RPC `action`, which may be a + * side effect the inspector shouldn't fire unprompted). + * + * Throws `DP_INSPECT_0003` when this connection has no hub commands host + * (not mounted inside `@devframes/hub`). A found-but-unregistered id, or a + * group-only command with no handler, surfaces as `{ ok: false, error }` + * from the hub's own `commands.execute()` instead of throwing. + */ +export const executeCommand = defineInspectRpc({ + name: 'devframes:plugin:inspect:execute-command', + type: 'action', + setup: ctx => ({ + handler: async (id: string, args: unknown[] = []): Promise => { + const host = resolveHubCommands(ctx) + if (!host) + throw diagnostics.DP_INSPECT_0003({ id }) + + const start = Date.now() + try { + const result = await host.execute(id, ...args) + return { ok: true, result, durationMs: Date.now() - start } + } + catch (error) { + const e = error as Error + return { + ok: false, + error: { + name: e?.name ?? 'Error', + message: e?.message ?? String(error), + stack: e?.stack, + }, + durationMs: Date.now() - start, + } + } + }, + }), +}) diff --git a/plugins/inspect/src/rpc/functions/list-commands.ts b/plugins/inspect/src/rpc/functions/list-commands.ts new file mode 100644 index 00000000..ec7f90fd --- /dev/null +++ b/plugins/inspect/src/rpc/functions/list-commands.ts @@ -0,0 +1,33 @@ +import type { DevframeInspectCommandInfo } from '../../types' +import { defineInspectRpc } from './_define' +import { projectCommand, resolveHubCommands } from './_hub-commands' + +/** + * Enumerate every command registered on the hub's commands host — id, + * title, description, icon, category, whether it carries its own handler, + * and nested children — when this connection is mounted inside a hub + * (`@devframes/hub`). Returns an empty list on a plain devframe connection, + * so the inspector's Commands tab degrades gracefully rather than erroring. + * `snapshot: true` bakes the (possibly empty) list into the static dump so + * the inspector still lists commands in `build`/`spa` mode. + */ +export const listCommands = defineInspectRpc({ + name: 'devframes:plugin:inspect:list-commands', + type: 'query', + jsonSerializable: true, + snapshot: true, + agent: { + description: 'List every command registered on the hub commands host (id, title, description, category, whether it has a handler, children), when this connection is mounted inside a hub. Read-only. Empty outside a hub.', + title: 'List hub commands', + }, + setup: ctx => ({ + handler: async (): Promise => { + const host = resolveHubCommands(ctx) + if (!host) + return [] + const out = [...host.commands.values()].map(projectCommand) + out.sort((a, b) => a.title.localeCompare(b.title)) + return out + }, + }), +}) diff --git a/plugins/inspect/src/rpc/index.ts b/plugins/inspect/src/rpc/index.ts index c4fd25f3..0ebe8d0e 100644 --- a/plugins/inspect/src/rpc/index.ts +++ b/plugins/inspect/src/rpc/index.ts @@ -1,7 +1,9 @@ import type { RpcDefinitionsToFunctions } from 'devframe/rpc' import { describeAgent } from './functions/describe-agent' +import { executeCommand } from './functions/execute-command' import { invoke } from './functions/invoke' import { invokeAgentTool } from './functions/invoke-agent-tool' +import { listCommands } from './functions/list-commands' import { listFunctions } from './functions/list-functions' import { listStateKeys } from './functions/list-state-keys' import { readAgentResource } from './functions/read-agent-resource' @@ -17,6 +19,8 @@ export const serverFunctions = [ describeAgent, invokeAgentTool, readAgentResource, + listCommands, + executeCommand, ] as const declare module 'devframe' { diff --git a/plugins/inspect/src/spa/App.vue b/plugins/inspect/src/spa/App.vue index adccd464..e14ff1db 100644 --- a/plugins/inspect/src/spa/App.vue +++ b/plugins/inspect/src/spa/App.vue @@ -14,13 +14,14 @@ import { connectionTitle, } from '../../../../design/design' import AgentSmart from './components/AgentSmart.vue' +import CommandsSmart from './components/CommandsSmart.vue' import FunctionsSmart from './components/FunctionsSmart.vue' import HistorySmart from './components/HistorySmart.vue' import StateSmart from './components/StateSmart.vue' import { useRefresh } from './composables/refresh' import { connect, connection } from './composables/rpc' -type Tab = 'functions' | 'state' | 'agent' | 'history' +type Tab = 'functions' | 'state' | 'agent' | 'commands' | 'history' const tab = ref('functions') const { refresh, loading } = useRefresh() @@ -37,6 +38,7 @@ const tabs: { value: Tab, label: string, icon: string }[] = [ { value: 'functions', label: 'Functions', icon: 'i-ph-function-duotone' }, { value: 'state', label: 'State', icon: 'i-ph-database-duotone' }, { value: 'agent', label: 'Agent', icon: 'i-ph-robot-duotone' }, + { value: 'commands', label: 'Commands', icon: 'i-ph-terminal-window-duotone' }, { value: 'history', label: 'History', icon: 'i-ph-clock-counter-clockwise-duotone' }, ] @@ -102,6 +104,7 @@ function reload(): void { + diff --git a/plugins/inspect/src/spa/components/CommandRow.vue b/plugins/inspect/src/spa/components/CommandRow.vue new file mode 100644 index 00000000..36f64161 --- /dev/null +++ b/plugins/inspect/src/spa/components/CommandRow.vue @@ -0,0 +1,111 @@ + + +