Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions plugins/inspect/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion plugins/inspect/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions plugins/inspect/src/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).',
},
},
})
61 changes: 61 additions & 0 deletions plugins/inspect/src/rpc/functions/_hub-commands.ts
Original file line number Diff line number Diff line change
@@ -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<string, HubCommandLike>
execute: (id: string, ...args: any[]) => Promise<unknown>
}

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),
}
}
46 changes: 46 additions & 0 deletions plugins/inspect/src/rpc/functions/execute-command.ts
Original file line number Diff line number Diff line change
@@ -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<InvokeResult> => {
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,
}
}
},
}),
})
33 changes: 33 additions & 0 deletions plugins/inspect/src/rpc/functions/list-commands.ts
Original file line number Diff line number Diff line change
@@ -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<DevframeInspectCommandInfo[]> => {
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
},
}),
})
4 changes: 4 additions & 0 deletions plugins/inspect/src/rpc/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -17,6 +19,8 @@ export const serverFunctions = [
describeAgent,
invokeAgentTool,
readAgentResource,
listCommands,
executeCommand,
] as const

declare module 'devframe' {
Expand Down
5 changes: 4 additions & 1 deletion plugins/inspect/src/spa/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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<Tab>('functions')
const { refresh, loading } = useRefresh()
Expand All @@ -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' },
]

Expand Down Expand Up @@ -102,6 +104,7 @@ function reload(): void {
<FunctionsSmart v-if="tab === 'functions'" />
<StateSmart v-else-if="tab === 'state'" />
<AgentSmart v-else-if="tab === 'agent'" />
<CommandsSmart v-else-if="tab === 'commands'" />
<HistorySmart v-else-if="tab === 'history'" />
</template>
</main>
Expand Down
111 changes: 111 additions & 0 deletions plugins/inspect/src/spa/components/CommandRow.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<script setup lang="ts">
import type { DevframeInspectCommandInfo } from '@devframes/plugin-inspect/client'
import ActionButton from '@antfu/design/components/Action/ActionButton.vue'
import { computed, ref } from 'vue'
import JsonView from './JsonView.vue'

defineOptions({ name: 'CommandRow' })

const props = defineProps<{
cmd: DevframeInspectCommandInfo
argsInput: Record<string, string>
results: Record<string, any>
pending: Record<string, boolean>
isStatic: boolean
/** Nesting depth — 0 for top-level commands, 1 for their children. */
depth?: number
}>()

const emit = defineEmits<{
(e: 'execute', cmd: DevframeInspectCommandInfo): void
(e: 'updateArgs', id: string, value: string): void
}>()

const expanded = ref(false)

const clickable = computed(() =>
props.cmd.hasHandler || !!props.cmd.description || !!props.cmd.children?.length,
)

function toggle(): void {
expanded.value = !expanded.value
}
</script>

<template>
<div class="fn-row" :class="{ 'cmd-row-child': depth }">
<div class="fn-head" :class="{ clickable }" @click="toggle">
<div class="chev i-ph-caret-right" :class="{ open: expanded }" />
<span v-if="cmd.icon" :class="typeof cmd.icon === 'string' ? cmd.icon : 'i-ph-terminal-window-duotone'" class="cmd-icon" />
<span class="cmd-title">{{ cmd.title }}</span>
<span class="cmd-id mono">{{ cmd.id }}</span>
<span class="fn-flags">
<span v-if="cmd.category" class="badge flag">{{ cmd.category }}</span>
<span v-if="!cmd.hasHandler" class="badge flag" title="No handler — a group for its children">group</span>
<span v-if="cmd.children?.length" class="badge flag">{{ cmd.children.length }} children</span>
</span>
</div>

<div v-if="expanded" class="fn-detail">
<p v-if="cmd.description" class="desc">
{{ cmd.description }}
</p>

<template v-if="cmd.hasHandler">
<div class="label">
Execute — positional args as a JSON array
</div>
<textarea :value="argsInput[cmd.id]" class="args" spellcheck="false" placeholder="[]" @input="emit('updateArgs', cmd.id, ($event.target as HTMLTextAreaElement).value)" />
<div style="margin-top: 8px; display: flex; gap: 8px; align-items: center;">
<ActionButton
variant="primary"
size="sm"
icon="i-ph-play-duotone"
:loading="pending[cmd.id]"
:disabled="pending[cmd.id] || isStatic"
@click.stop="emit('execute', cmd)"
>
{{ pending[cmd.id] ? 'Running…' : 'Run' }}
</ActionButton>
<span v-if="isStatic" class="note">read-only static backend — execution disabled</span>
</div>
<div v-if="results[cmd.id]" class="result">
<div class="result-head">
<span v-if="results[cmd.id].ok" class="ok">✓ resolved</span>
<span v-else class="fail">✕ threw</span>
<span v-if="'durationMs' in results[cmd.id]" class="muted">{{ results[cmd.id].durationMs }}ms</span>
</div>
<JsonView
v-if="results[cmd.id].ok"
:value="results[cmd.id].result"
:expand-depth="2"
/>
<JsonView v-else :value="results[cmd.id].error" :expand-depth="2" />
</div>
</template>
<p v-else-if="!cmd.children?.length" class="note">
Group-only command — no handler to run.
</p>

<template v-if="cmd.children?.length">
<div class="label">
Children
</div>
<div class="cmd-children">
<CommandRow
v-for="child in cmd.children"
:key="child.id"
:cmd="child"
:args-input="argsInput"
:results="results"
:pending="pending"
:is-static="isStatic"
:depth="(depth ?? 0) + 1"
@execute="emit('execute', $event)"
@update-args="(id, value) => emit('updateArgs', id, value)"
/>
</div>
</template>
</div>
</div>
</template>
47 changes: 47 additions & 0 deletions plugins/inspect/src/spa/components/CommandsSmart.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<script setup lang="ts">
import type { DevframeInspectCommandInfo, InvokeResult } from '@devframes/plugin-inspect/client'
import { onMounted, reactive, shallowRef } from 'vue'
import { useRefreshProvider } from '../composables/refresh'
import { isStatic, useRpc } from '../composables/rpc'
import CommandsView from './CommandsView.vue'

const rpc = useRpc()
const commands = shallowRef<DevframeInspectCommandInfo[] | null>(null)
const results = reactive<Record<string, InvokeResult | { ok: false, error: { name: string, message: string } }>>({})
const pending = reactive<Record<string, boolean>>({})

async function fetchData(): Promise<void> {
if (!rpc.value)
return
commands.value = await rpc.value.call('devframes:plugin:inspect:list-commands')
}

useRefreshProvider(fetchData)
onMounted(fetchData)

async function onExecute(cmd: DevframeInspectCommandInfo, parsedArgs: unknown[]): Promise<void> {
if (!rpc.value)
return
pending[cmd.id] = true
try {
results[cmd.id] = await rpc.value.call('devframes:plugin:inspect:execute-command', cmd.id, parsedArgs)
}
catch (e) {
const err = e as Error
results[cmd.id] = { ok: false, error: { name: err?.name ?? 'Error', message: err?.message ?? String(e) } }
}
finally {
pending[cmd.id] = false
}
}
</script>

<template>
<CommandsView
:commands="commands"
:is-static="isStatic()"
:results="results"
:pending="pending"
@execute="onExecute"
/>
</template>
Loading
Loading