Standalone CLI with Devframe

npx my-tool starts a dev server serving a Vue/Nuxt/React SPA over type-safe RPC, plus build/mcp.

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

Minimal CLI

src/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 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 exposure

Nuxt SPA setup

The Nuxt helper sets app.baseURL: './' / vite.base: './' and wires connectDevframe() into $rpc (Nuxt docs).

nuxt.config.ts
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:

next.config.mjs
/** @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:

package.json
{
  "scripts": {
    "build": "next build src/client && rm -rf dist/client && mkdir -p dist && cp -r src/client/out dist/client"
  }
}
src/cli.ts
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:

app/composables/payload.ts
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.

src/cli.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 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.

src/cli.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 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