Skip to content

Standalone CLI with Devframe ​

This recipe walks through building a standalone CLI devframe on top of Devframe — the shape where a user runs npx my-tool and gets a local dev server serving a Vue / Nuxt / React SPA backed by type-safe RPC, plus build / spa / mcp subcommands for free.

It's the pattern used by tools like an ESLint config inspector or a bundler-config viewer: a binary that opens a browser.

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.json

Minimal CLI ​

ts
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 distDir = resolve(import.meta.dirname, '../dist/public')

const devframe = defineDevframe({
  id: 'my-tool',
  name: 'My Tool',
  cli: {
    command: 'my-tool',
    distDir,
    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(devframe, {
  onReady({ origin }) {
    console.log(c.green`My Tool ready at ${origin}`)
  },
}).parse(process.argv)

Run:

sh
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 exposure (experimental)

Nuxt SPA setup ​

For the Nuxt side, add the devframe helper module — it sets app.baseURL: './' / vite.base: './', injects a client plugin that wires connectDevframe() into useNuxtApp().$rpc, and exposes the typed RPC client to the whole app:

ts
export default defineNuxtConfig({
  ssr: false,
  modules: ['@devframes/nuxt'],
  nitro: {
    preset: 'static',
    output: { dir: './dist' }, // matches createCac's distDir of ./dist/public
  },
})

Build with nuxt build and point cli.distDir at ./dist/public. The SPA discovers its effective base at runtime — no --base rewrite needed. See the Nuxt helper docs for the full reference.

Next.js SPA setup ​

For a Next.js App Router SPA, the integration is plain Next.js static export — devframe owns the HTTP and RPC server, Next.js produces the static bundle and stops there. Three config settings cover the integration:

js
/** @type {import('next').NextConfig} */
export default {
  output: 'export',
  assetPrefix: '.',
  trailingSlash: true,
  images: { unoptimized: true },
}
  • output: 'export' emits the SPA as static HTML/JS/CSS — no Next.js runtime is needed at serve time. Server Components are pre-rendered at build; Client Components hydrate against the devframe RPC connection.
  • assetPrefix: '.' is the setting that makes the build base-agnostic. Assets are referenced as ./_next/... so the same bundle works at /, /__my-tool/, and any other mount path the host adapter chooses. Without it, Next.js bakes in /_next/... and the build only works at the root.
  • trailingSlash: true emits foo/index.html rather than foo.html, which composes cleanly with devframe's static-handler directory-with-index resolution.

next build writes the export to <project>/out/ next to next.config.mjs. Copy or move that to wherever you point cli.distDir:

json
{
  "scripts": {
    "build": "next build src/client && rm -rf dist/client && mkdir -p dist && cp -r src/client/out dist/client"
  }
}
ts
import { fileURLToPath } from 'node:url'

defineDevframe({
  id: 'my-tool',
  cli: {
    distDir: fileURLToPath(new URL('../dist/client', import.meta.url)),
  },
  // …
})

Inside Client Components, call connectDevframe() once and share the result via React context. See Client for the full reference — the Next.js side is plain React, with no devframe-specific wrapper.

End-to-end example: examples/next-runtime-snapshot.

Connecting from the client ​

With the Nuxt helper installed, use $rpc directly:

ts
export async function fetchPayload() {
  const { $rpc } = useNuxtApp()
  return $rpc.call('my-tool:get-payload')
}

For non-Nuxt frontends (Vite + Vue, React, plain HTML, etc.), call connectDevframe() yourself:

ts
import { connectDevframe } from 'devframe/client'

const my = (await connectDevframe()).scope('my-tool')
const payload = await my.rpc.call('get-payload')

connectDevframe auto-resolves the connection descriptor relative to the current page — it works both in dev (WebSocket backend) and in the built static snapshot (static backend reads the baked RPC dump).

Typed CLI flags ​

For flags that are specific to your tool, declare them with any Standard Schema validator (valibot below — npm i valibot, the lightest option — or zod / arktype) so they're validated at parse time and typed at the call site. If you already depend on zod through the JSON-render or MCP integrations, prefer zod here to avoid adding a second validator:

ts
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',
  cli: {
    distDir,
    flags: appFlags,
  },
  setup(ctx, info) {
    const flags = info.flags as InferCliFlags<typeof appFlags>
    flags.depth // number
    flags.config // string | undefined
  },
})

The adapter derives each flag's CAC option from its schema — booleans become --verbose / --no-verbose; everything else becomes --depth <value>. Keys are camelCase in TypeScript, kebab-case on the command line (configFile → --config-file). Flags that aren't in your schema (--host, --port, or anything added via cli.configure) still pass through untouched.

Common RPC functions ​

For the two actions every CLI devtool needs — open a file in the editor, reveal a path in the OS file explorer — use the prebuilt recipes from devframe/recipes/common-rpc-functions instead of re-implementing them. See Helpers → Common RPC Functions for the full reference.

Snapshot queries for static builds ​

When an RPC function's single job is to return one payload per build (no arguments that vary), set snapshot: true so the build adapter runs the handler once and bakes the result into the dump:

ts
defineRpcFunction({
  name: 'my-tool:get-payload',
  type: 'query',
  snapshot: true,
  handler() {
    return scanPackages(flags.root)
  },
})

At build time the handler runs once with no arguments; the result is stored as both the no-args record and the fallback, so rpc.call('my-tool:get-payload', anything) from the deployed SPA resolves to the same snapshot. In dev mode the function behaves as a normal query over WebSocket — call variants with different args invoke the live handler.

On-disk caching ​

Persistence between runs is the application's job — unstorage is the recommended pattern. Keep cache paths under node_modules/.cache/<your-devtool-id>/ so the cache rotates with the project's pnpm install:

ts
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 belongs to the application layer — wire your own chokidar and signal the client via shared state:

ts
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 client, subscribe to the version key and refetch:

ts
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 ​

createCac is a convenience wrapper around three lower-level factories — reach for them directly when you already own a CLI framework (commander, yargs, oclif, hand-rolled cac) or want a different command structure:

Building blockEntry
createDevServer(def, opts?)devframe/adapters/dev
createBuild(def, opts?)devframe/adapters/build
createMcpServer(def, opts?)devframe/adapters/mcp

Each one runs against the same DevframeDefinition you'd pass to createCac. A commander example:

ts
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 devframe = defineDevframe({
  id: 'my-tool',
  name: 'My Tool',
  cli: { distDir: './dist/public', 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(devframe, {
      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(devframe, { outDir: opts.outDir }))

await program.parseAsync()

createDevServer returns the underlying StartedServer handle (origin, port, app, ws, rpcGroup, connectionMeta(), close()) so the surrounding program can drive graceful shutdown — SIGINT, hot reload, integration tests.

For typed flag schemas, parseCliFlags(schema, rawBag) (from devframe/adapters/cac) validates a commander/yargs flag bag against a CliFlagsSchema (the same defineCliFlags(...) value you'd put on cli.flags). The helper is framework-agnostic, so typed-schema validation works with any CLI framework.

Why this shape ​

  • One command, one binary. createCac is a complete CLI — dev, build, spa, mcp all from a single defineDevframe value.
  • Headless. Your onReady callback owns startup output, so your tool's stdout stays yours.
  • Base-agnostic. Same SPA build works at / (dev, standalone static) and at any deployment base.
  • Typed end-to-end. RPC function definitions flow their types through to the client rpc.call site.
  • Agent-ready. Add agent: { description } to any RPC function to expose it through the mcp subcommand.

See also ​

Released under the MIT License.