Standalone CLI with Devframe
npx my-tool starts a dev server serving a Vue/Nuxt/React SPA over type-safe RPC, plus build/mcp.
What you ship
my-tool/
├── bin.mjs # shebang + import './dist/cli.mjs'
├── src/
│ ├── cli.ts # defineDevframe + createCac
│ ├── rpc.ts # your RPC function definitions
│ └── data.ts # your domain-specific logic
├── app/ # Nuxt / Vue / React SPA source
├── dist/
│ ├── public/ # built SPA output (served at /)
│ └── cli.mjs # bundled node entry
└── package.jsonMinimal CLI
import process from 'node:process'
import { defineDevframe, defineRpcFunction } from 'devframe'
import { createCac } from 'devframe/adapters/cac'
import { colors as c } from 'devframe/utils/colors'
import { resolve } from 'pathe'
const clientAssets = resolve(import.meta.dirname, '../dist/public')
const myDevframe = defineDevframe({
id: 'my-tool',
name: 'My Tool',
clientAssets,
cli: {
command: 'my-tool',
port: 7777,
portRange: [7777, 9000],
open: true, // auth defaults to on; `--open` embeds the current OTP so the tab lands authenticated
configure(cli) {
cli
.option('--config <file>', 'Config file path')
.option('--base-path <dir>', 'Base directory for resolution')
},
},
async setup(ctx, { flags }) {
const my = ctx.scope('my-tool')
my.rpc.register(defineRpcFunction({
name: 'get-payload', // -> my-tool:get-payload
type: 'query',
async handler() {
return await loadPayload({
configPath: flags.config,
basePath: flags.basePath,
})
},
}))
},
})
await createCac(myDevframe, {
onReady({ origin }) {
console.log(c.green`My Tool ready at ${origin}`)
},
}).parse(process.argv)Run:
my-tool # dev server at http://localhost:7777/
my-tool --config ./my.config.mjs
my-tool --port 8080 --no-open
my-tool build --out-dir dist-static # self-contained static deploy
my-tool build --out-dir dist-static --base /tool/ # …under a custom base
my-tool mcp # agent exposureNuxt SPA setup
The Nuxt helper sets app.baseURL: './' / vite.base: './' and wires connectDevframe() into $rpc (Nuxt docs).
export default defineNuxtConfig({
ssr: false,
modules: ['@devframes/nuxt/single'],
nitro: {
preset: 'static',
output: { dir: './dist' }, // matches the definition's clientAssets of ./dist/public
},
})Next.js SPA setup
For a Next.js App Router SPA, static export needs:
/** @type {import('next').NextConfig} */
export default {
output: 'export',
assetPrefix: '.',
trailingSlash: true,
images: { unoptimized: true },
}assetPrefix: '.' keeps assets base-agnostic; trailingSlash: true emits foo/index.html for directory-with-index resolution. Copy next build's out/ to clientAssets:
{
"scripts": {
"build": "next build src/client && rm -rf dist/client && mkdir -p dist && cp -r src/client/out dist/client"
}
}import { fileURLToPath } from 'node:url'
defineDevframe({
id: 'my-tool',
clientAssets: fileURLToPath(new URL('../dist/client', import.meta.url)),
// …
})Call connectDevframe() in a Client Component; see Client and examples/next-runtime-snapshot.
Connecting from the browser side
With the Nuxt helper, use $rpc:
export async function fetchPayload() {
const { $rpc } = useNuxtApp()
return $rpc.call('my-tool:get-payload')
}Otherwise call connectDevframe(), which auto-resolves the connection descriptor relative to the page, whether dev (WebSocket) or static snapshot:
import { connectDevframe } from 'devframe/client'
const my = (await connectDevframe()).scope('my-tool')
const payload = await my.rpc.call('get-payload')Typed CLI flags
Declare tool flags with any Standard Schema validator (valibot/zod/arktype), validated at parse and typed at the call site:
import type { InferCliFlags } from 'devframe/adapters/cac'
import { defineDevframe } from 'devframe'
import { defineCliFlags } from 'devframe/adapters/cac'
import * as v from 'valibot' // npm i valibot
const appFlags = defineCliFlags({
depth: v.pipe(v.number(), v.integer()),
config: v.optional(v.string()),
verbose: v.optional(v.boolean()),
})
defineDevframe({
id: 'my-tool',
name: 'My Tool',
clientAssets,
cli: {
flags: appFlags,
},
setup(ctx, info) {
const flags = info.flags as InferCliFlags<typeof appFlags>
flags.depth // number
flags.config // string | undefined
},
})Booleans become --verbose / --no-verbose, else --depth <value>; keys are camelCase in TS, kebab-case on the CLI (configFile → --config-file). Flags outside the schema pass through.
Snapshot queries for static builds
For an RPC function returning one payload per build, set snapshot: true; the build adapter runs the handler once, baking the result in:
defineRpcFunction({
name: 'my-tool:get-payload',
type: 'query',
snapshot: true,
handler() {
return scanPackages(flags.root)
},
})It's the no-args fallback for any deployed rpc.call('my-tool:get-payload', …); a normal query in dev.
On-disk caching
Persistence is your tool's job (unstorage recommended); keep cache paths under node_modules/.cache/<your-devtool-id>/ to rotate with pnpm install.
import { resolve } from 'pathe'
import { createStorage } from 'unstorage'
import fsDriver from 'unstorage/drivers/fs'
const cache = createStorage({
driver: fsDriver({
base: resolve(process.cwd(), 'node_modules/.cache/my-tool'),
}),
})
defineDevframe({
id: 'my-tool',
name: 'My Tool',
async setup(ctx) {
ctx.scope('my-tool').rpc.register(defineRpcFunction({
name: 'get-npm-meta', // -> my-tool:get-npm-meta
type: 'query',
async handler(spec: string) {
return (await cache.getItem(spec))
?? await fetchAndCache(spec, cache)
},
}))
},
})Live-reload on config changes
Filesystem watching is your tool's job: wire chokidar, signal the browser side via shared state.
defineDevframe({
id: 'my-tool',
name: 'My Tool',
async setup(ctx, { flags }) {
const my = ctx.scope('my-tool')
my.rpc.register(defineRpcFunction({
name: 'get-payload', // -> my-tool:get-payload
type: 'query',
cacheable: true,
handler: () => loadPayload({ configPath: flags.config }),
}))
if (ctx.mode === 'dev') {
const version = await my.rpc.sharedState('version', { initialValue: { ts: 0 } })
const { default: chokidar } = await import('chokidar')
const watcher = chokidar.watch(flags.config ?? [], { ignoreInitial: true })
watcher.on('change', () => {
version.mutate((draft) => {
draft.ts = Date.now()
})
})
}
},
})On the browser side:
const my = (await connectDevframe()).scope('my-tool')
const version = await my.rpc.sharedState('version')
version.on('updated', () => fetchPayload().then(setData))Use your own CLI framework
Own a CLI framework (commander, yargs, oclif)? Use the three factories createCac wraps against one DevframeDefinition: createDevServer (devframe/adapters/dev), createBuild (devframe/adapters/build), and createMcpServer (devframe/adapters/mcp); see the CLI adapter.
import process from 'node:process'
import { Command } from 'commander'
import { defineDevframe } from 'devframe'
import { createBuild } from 'devframe/adapters/build'
import { createDevServer } from 'devframe/adapters/dev'
const myDevframe = defineDevframe({
id: 'my-tool',
name: 'My Tool',
clientAssets: './dist/public',
cli: { port: 7777 },
setup(ctx, { flags }) { /* ... */ },
})
const program = new Command('my-tool')
program
.command('dev', { isDefault: true })
.option('-p, --port <port>', 'Port', '7777')
.option('--config <file>', 'Config file path')
.action(async (opts) => {
const handle = await createDevServer(myDevframe, {
port: Number(opts.port),
flags: { config: opts.config },
onReady: ({ origin }) => console.log(`Ready at ${origin}`),
})
process.on('SIGINT', () => handle.close().then(() => process.exit(0)))
})
program
.command('build')
.option('--out-dir <dir>', 'Output directory', 'dist-static')
.action(opts => createBuild(myDevframe, { outDir: opts.outDir }))
await program.parseAsync()createDevServer returns a StartedServer handle (origin, port, app, ws, rpcGroup, connectionMeta(), close()). For typed flags, parseCliFlags(schema, rawBag) (devframe/adapters/cac) validates a commander/yargs bag against the cli.flags CliFlagsSchema.
See also
- Devframe Definition
- Adapters → CLI (cac):
configureCli, mount-path rules - Adapters → Dev
- Client
- Agent-Native
Structured Diagnostics
ctx.diagnostics is a thin layer over nostics for author-defined coded diagnostics, each with a stable code, docs URL, and structured payload.
Client
The RPC client connects any surface (dock iframe, remote page, standalone SPA) to a devframe's node side with type-safe RPC, shared state, and a trust handshake.