Skip to content

Commit b88650d

Browse files
committed
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).
1 parent 9715c86 commit b88650d

18 files changed

Lines changed: 701 additions & 9 deletions

plugins/inspect/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
},
6363
"devDependencies": {
6464
"@antfu/design": "catalog:frontend",
65+
"@devframes/hub": "workspace:*",
6566
"@iconify-json/ph": "catalog:frontend",
6667
"@storybook/addon-docs": "catalog:storybook",
6768
"@storybook/vue3-vite": "catalog:storybook",

plugins/inspect/src/client/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { DevframeConnectionStatus, DevframeRpcClient, DevframeRpcClientOpti
22
import { connectDevframe } from 'devframe/client'
33

44
export type { DevframeConnectionStatus, DevframeRpcClient }
5-
export type { AgentManifest, InvokeResult, RpcFunctionAgentInfo, RpcFunctionInfo } from '../types'
5+
export type { AgentManifest, DevframeInspectCommandInfo, InvokeResult, RpcFunctionAgentInfo, RpcFunctionInfo } from '../types'
66

77
/**
88
* Connect to the inspector's devframe backend. A thin, typed wrapper

plugins/inspect/src/diagnostics.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,10 @@ export const diagnostics = defineDiagnostics({
1919
`Refusing to invoke "${p.name}" — only read-only "query" and "static" functions are invokable from the inspector, but this one is "${p.type}".`,
2020
fix: 'The inspector deliberately blocks `action`/`event` functions to avoid triggering side effects. Invoke those through their own UI instead.',
2121
},
22+
DP_INSPECT_0003: {
23+
why: (p: { id: string }) =>
24+
`Cannot execute command "${p.id}" — this connection has no hub commands host.`,
25+
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).',
26+
},
2227
},
2328
})
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import type { DevframeInspectCommandInfo } from '../../types'
2+
3+
/**
4+
* Minimal shape of the hub's commands host (`DevframeHubContext.commands`,
5+
* `@devframes/hub/src/types/commands.ts`) that this plugin reads from.
6+
* Declared locally and accessed by duck-typing so the inspector keeps no
7+
* build/runtime dependency on `@devframes/hub` and degrades to an empty
8+
* list / a thrown diagnostic outside a hub.
9+
*/
10+
export interface HubCommandLike {
11+
id: string
12+
title: string
13+
description?: string
14+
icon?: string | { light: string, dark: string }
15+
category?: string
16+
handler?: (...args: any[]) => any
17+
children?: HubCommandLike[]
18+
}
19+
20+
export interface HubCommandsHostLike {
21+
commands: Map<string, HubCommandLike>
22+
execute: (id: string, ...args: any[]) => Promise<unknown>
23+
}
24+
25+
function looksLikeHubCommandsHost(value: unknown): value is HubCommandsHostLike {
26+
return !!value
27+
&& typeof value === 'object'
28+
&& (value as { commands?: unknown }).commands instanceof Map
29+
&& typeof (value as { execute?: unknown }).execute === 'function'
30+
}
31+
32+
/**
33+
* Resolve `ctx.commands` when this connection is mounted inside a hub, or
34+
* `undefined` on a plain devframe connection (CLI / Vite / build without a
35+
* hub). Structural rather than an `instanceof`/import check, matching the
36+
* pattern established in `plugins/terminals/src/node/manager.ts`.
37+
*/
38+
export function resolveHubCommands(ctx: object): HubCommandsHostLike | undefined {
39+
const commands = (ctx as { commands?: unknown }).commands
40+
return looksLikeHubCommandsHost(commands) ? commands : undefined
41+
}
42+
43+
/**
44+
* Project a raw hub command (still carrying its `handler`) into the
45+
* serializable shape the inspector sends over RPC, recursing into
46+
* children. `hasHandler` survives the projection even though the hub's
47+
* own `commands.list()` strips `handler` entirely before it's callable
48+
* from here — reading the raw command straight off `host.commands`
49+
* preserves that flag.
50+
*/
51+
export function projectCommand(cmd: HubCommandLike): DevframeInspectCommandInfo {
52+
return {
53+
id: cmd.id,
54+
title: cmd.title,
55+
description: cmd.description,
56+
icon: cmd.icon,
57+
category: cmd.category,
58+
hasHandler: typeof cmd.handler === 'function',
59+
children: cmd.children?.map(projectCommand),
60+
}
61+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import type { InvokeResult } from '../../types'
2+
import { diagnostics } from '../../diagnostics'
3+
import { defineInspectRpc } from './_define'
4+
import { resolveHubCommands } from './_hub-commands'
5+
6+
/**
7+
* Execute a hub command by id and return a result envelope, mirroring
8+
* `devframes:plugin:inspect:invoke`. Unlike that function, this one is not
9+
* gated to read-only types — a command is, by construction, something a
10+
* user explicitly runs (unlike an arbitrary RPC `action`, which may be a
11+
* side effect the inspector shouldn't fire unprompted).
12+
*
13+
* Throws `DP_INSPECT_0003` when this connection has no hub commands host
14+
* (not mounted inside `@devframes/hub`). A found-but-unregistered id, or a
15+
* group-only command with no handler, surfaces as `{ ok: false, error }`
16+
* from the hub's own `commands.execute()` instead of throwing.
17+
*/
18+
export const executeCommand = defineInspectRpc({
19+
name: 'devframes:plugin:inspect:execute-command',
20+
type: 'action',
21+
setup: ctx => ({
22+
handler: async (id: string, args: unknown[] = []): Promise<InvokeResult> => {
23+
const host = resolveHubCommands(ctx)
24+
if (!host)
25+
throw diagnostics.DP_INSPECT_0003({ id })
26+
27+
const start = Date.now()
28+
try {
29+
const result = await host.execute(id, ...args)
30+
return { ok: true, result, durationMs: Date.now() - start }
31+
}
32+
catch (error) {
33+
const e = error as Error
34+
return {
35+
ok: false,
36+
error: {
37+
name: e?.name ?? 'Error',
38+
message: e?.message ?? String(error),
39+
stack: e?.stack,
40+
},
41+
durationMs: Date.now() - start,
42+
}
43+
}
44+
},
45+
}),
46+
})
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { DevframeInspectCommandInfo } from '../../types'
2+
import { defineInspectRpc } from './_define'
3+
import { projectCommand, resolveHubCommands } from './_hub-commands'
4+
5+
/**
6+
* Enumerate every command registered on the hub's commands host — id,
7+
* title, description, icon, category, whether it carries its own handler,
8+
* and nested children — when this connection is mounted inside a hub
9+
* (`@devframes/hub`). Returns an empty list on a plain devframe connection,
10+
* so the inspector's Commands tab degrades gracefully rather than erroring.
11+
* `snapshot: true` bakes the (possibly empty) list into the static dump so
12+
* the inspector still lists commands in `build`/`spa` mode.
13+
*/
14+
export const listCommands = defineInspectRpc({
15+
name: 'devframes:plugin:inspect:list-commands',
16+
type: 'query',
17+
jsonSerializable: true,
18+
snapshot: true,
19+
agent: {
20+
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.',
21+
title: 'List hub commands',
22+
},
23+
setup: ctx => ({
24+
handler: async (): Promise<DevframeInspectCommandInfo[]> => {
25+
const host = resolveHubCommands(ctx)
26+
if (!host)
27+
return []
28+
const out = [...host.commands.values()].map(projectCommand)
29+
out.sort((a, b) => a.title.localeCompare(b.title))
30+
return out
31+
},
32+
}),
33+
})

plugins/inspect/src/rpc/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import type { RpcDefinitionsToFunctions } from 'devframe/rpc'
22
import { describeAgent } from './functions/describe-agent'
3+
import { executeCommand } from './functions/execute-command'
34
import { invoke } from './functions/invoke'
45
import { invokeAgentTool } from './functions/invoke-agent-tool'
6+
import { listCommands } from './functions/list-commands'
57
import { listFunctions } from './functions/list-functions'
68
import { listStateKeys } from './functions/list-state-keys'
79
import { readAgentResource } from './functions/read-agent-resource'
@@ -17,6 +19,8 @@ export const serverFunctions = [
1719
describeAgent,
1820
invokeAgentTool,
1921
readAgentResource,
22+
listCommands,
23+
executeCommand,
2024
] as const
2125

2226
declare module 'devframe' {

plugins/inspect/src/spa/App.vue

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,14 @@ import {
1414
connectionTitle,
1515
} from '../../../../design/design'
1616
import AgentSmart from './components/AgentSmart.vue'
17+
import CommandsSmart from './components/CommandsSmart.vue'
1718
import FunctionsSmart from './components/FunctionsSmart.vue'
1819
import HistorySmart from './components/HistorySmart.vue'
1920
import StateSmart from './components/StateSmart.vue'
2021
import { useRefresh } from './composables/refresh'
2122
import { connect, connection } from './composables/rpc'
2223
23-
type Tab = 'functions' | 'state' | 'agent' | 'history'
24+
type Tab = 'functions' | 'state' | 'agent' | 'commands' | 'history'
2425
2526
const tab = ref<Tab>('functions')
2627
const { refresh, loading } = useRefresh()
@@ -37,6 +38,7 @@ const tabs: { value: Tab, label: string, icon: string }[] = [
3738
{ value: 'functions', label: 'Functions', icon: 'i-ph-function-duotone' },
3839
{ value: 'state', label: 'State', icon: 'i-ph-database-duotone' },
3940
{ value: 'agent', label: 'Agent', icon: 'i-ph-robot-duotone' },
41+
{ value: 'commands', label: 'Commands', icon: 'i-ph-terminal-window-duotone' },
4042
{ value: 'history', label: 'History', icon: 'i-ph-clock-counter-clockwise-duotone' },
4143
]
4244
@@ -102,6 +104,7 @@ function reload(): void {
102104
<FunctionsSmart v-if="tab === 'functions'" />
103105
<StateSmart v-else-if="tab === 'state'" />
104106
<AgentSmart v-else-if="tab === 'agent'" />
107+
<CommandsSmart v-else-if="tab === 'commands'" />
105108
<HistorySmart v-else-if="tab === 'history'" />
106109
</template>
107110
</main>
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
<script setup lang="ts">
2+
import type { DevframeInspectCommandInfo } from '@devframes/plugin-inspect/client'
3+
import ActionButton from '@antfu/design/components/Action/ActionButton.vue'
4+
import { computed, ref } from 'vue'
5+
import JsonView from './JsonView.vue'
6+
7+
defineOptions({ name: 'CommandRow' })
8+
9+
const props = defineProps<{
10+
cmd: DevframeInspectCommandInfo
11+
argsInput: Record<string, string>
12+
results: Record<string, any>
13+
pending: Record<string, boolean>
14+
isStatic: boolean
15+
/** Nesting depth — 0 for top-level commands, 1 for their children. */
16+
depth?: number
17+
}>()
18+
19+
const emit = defineEmits<{
20+
(e: 'execute', cmd: DevframeInspectCommandInfo): void
21+
(e: 'updateArgs', id: string, value: string): void
22+
}>()
23+
24+
const expanded = ref(false)
25+
26+
const clickable = computed(() =>
27+
props.cmd.hasHandler || !!props.cmd.description || !!props.cmd.children?.length,
28+
)
29+
30+
function toggle(): void {
31+
expanded.value = !expanded.value
32+
}
33+
</script>
34+
35+
<template>
36+
<div class="fn-row" :class="{ 'cmd-row-child': depth }">
37+
<div class="fn-head" :class="{ clickable }" @click="toggle">
38+
<div class="chev i-ph-caret-right" :class="{ open: expanded }" />
39+
<span v-if="cmd.icon" :class="typeof cmd.icon === 'string' ? cmd.icon : 'i-ph-terminal-window-duotone'" class="cmd-icon" />
40+
<span class="cmd-title">{{ cmd.title }}</span>
41+
<span class="cmd-id mono">{{ cmd.id }}</span>
42+
<span class="fn-flags">
43+
<span v-if="cmd.category" class="badge flag">{{ cmd.category }}</span>
44+
<span v-if="!cmd.hasHandler" class="badge flag" title="No handler — a group for its children">group</span>
45+
<span v-if="cmd.children?.length" class="badge flag">{{ cmd.children.length }} children</span>
46+
</span>
47+
</div>
48+
49+
<div v-if="expanded" class="fn-detail">
50+
<p v-if="cmd.description" class="desc">
51+
{{ cmd.description }}
52+
</p>
53+
54+
<template v-if="cmd.hasHandler">
55+
<div class="label">
56+
Execute — positional args as a JSON array
57+
</div>
58+
<textarea :value="argsInput[cmd.id]" class="args" spellcheck="false" placeholder="[]" @input="emit('updateArgs', cmd.id, ($event.target as HTMLTextAreaElement).value)" />
59+
<div style="margin-top: 8px; display: flex; gap: 8px; align-items: center;">
60+
<ActionButton
61+
variant="primary"
62+
size="sm"
63+
icon="i-ph-play-duotone"
64+
:loading="pending[cmd.id]"
65+
:disabled="pending[cmd.id] || isStatic"
66+
@click.stop="emit('execute', cmd)"
67+
>
68+
{{ pending[cmd.id] ? 'Running…' : 'Run' }}
69+
</ActionButton>
70+
<span v-if="isStatic" class="note">read-only static backend — execution disabled</span>
71+
</div>
72+
<div v-if="results[cmd.id]" class="result">
73+
<div class="result-head">
74+
<span v-if="results[cmd.id].ok" class="ok">✓ resolved</span>
75+
<span v-else class="fail">✕ threw</span>
76+
<span v-if="'durationMs' in results[cmd.id]" class="muted">{{ results[cmd.id].durationMs }}ms</span>
77+
</div>
78+
<JsonView
79+
v-if="results[cmd.id].ok"
80+
:value="results[cmd.id].result"
81+
:expand-depth="2"
82+
/>
83+
<JsonView v-else :value="results[cmd.id].error" :expand-depth="2" />
84+
</div>
85+
</template>
86+
<p v-else-if="!cmd.children?.length" class="note">
87+
Group-only command — no handler to run.
88+
</p>
89+
90+
<template v-if="cmd.children?.length">
91+
<div class="label">
92+
Children
93+
</div>
94+
<div class="cmd-children">
95+
<CommandRow
96+
v-for="child in cmd.children"
97+
:key="child.id"
98+
:cmd="child"
99+
:args-input="argsInput"
100+
:results="results"
101+
:pending="pending"
102+
:is-static="isStatic"
103+
:depth="(depth ?? 0) + 1"
104+
@execute="emit('execute', $event)"
105+
@update-args="(id, value) => emit('updateArgs', id, value)"
106+
/>
107+
</div>
108+
</template>
109+
</div>
110+
</div>
111+
</template>
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<script setup lang="ts">
2+
import type { DevframeInspectCommandInfo, InvokeResult } from '@devframes/plugin-inspect/client'
3+
import { onMounted, reactive, shallowRef } from 'vue'
4+
import { useRefreshProvider } from '../composables/refresh'
5+
import { isStatic, useRpc } from '../composables/rpc'
6+
import CommandsView from './CommandsView.vue'
7+
8+
const rpc = useRpc()
9+
const commands = shallowRef<DevframeInspectCommandInfo[] | null>(null)
10+
const results = reactive<Record<string, InvokeResult | { ok: false, error: { name: string, message: string } }>>({})
11+
const pending = reactive<Record<string, boolean>>({})
12+
13+
async function fetchData(): Promise<void> {
14+
if (!rpc.value)
15+
return
16+
commands.value = await rpc.value.call('devframes:plugin:inspect:list-commands')
17+
}
18+
19+
useRefreshProvider(fetchData)
20+
onMounted(fetchData)
21+
22+
async function onExecute(cmd: DevframeInspectCommandInfo, parsedArgs: unknown[]): Promise<void> {
23+
if (!rpc.value)
24+
return
25+
pending[cmd.id] = true
26+
try {
27+
results[cmd.id] = await rpc.value.call('devframes:plugin:inspect:execute-command', cmd.id, parsedArgs)
28+
}
29+
catch (e) {
30+
const err = e as Error
31+
results[cmd.id] = { ok: false, error: { name: err?.name ?? 'Error', message: err?.message ?? String(e) } }
32+
}
33+
finally {
34+
pending[cmd.id] = false
35+
}
36+
}
37+
</script>
38+
39+
<template>
40+
<CommandsView
41+
:commands="commands"
42+
:is-static="isStatic()"
43+
:results="results"
44+
:pending="pending"
45+
@execute="onExecute"
46+
/>
47+
</template>

0 commit comments

Comments
 (0)