# Standalone CLI with Devframe

> Source: <https://devfra.me/guide/standalone-cli>
> Published: 2026-09-23 16:30:29.670806+00:00

# 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](#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](#minimal-cli)

``` python
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](#nuxt-spa-setup)

The Nuxt helper sets `app.baseURL: './'` / `vite.base: './'` and wires `connectDevframe()` into `$rpc` ([Nuxt docs](https://devfra.me/frameworks/nuxt)).

```
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](#nextjs-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"
  }
}
js
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](https://devfra.me/guide/client) and [`examples/next-runtime-snapshot`](https://github.com/devframes/devframe/tree/main/examples/next-runtime-snapshot).

## [Connecting from the browser side](#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:

``` js
import { connectDevframe } from 'devframe/client'

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

## [Typed CLI flags](#typed-cli-flags)

Declare tool flags with any [Standard Schema](https://standardschema.dev/) validator (valibot/zod/arktype), validated at parse and typed at the call site:

``` python
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](#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](#on-disk-caching)

Persistence is your tool's job ([`unstorage`](https://unstorage.unjs.io/) recommended); keep cache paths under `node_modules/.cache/<your-devtool-id>/` to rotate with `pnpm install`.

``` js
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](#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:

``` js
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](#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](https://devfra.me/adapters/cac#use-your-own-cli-framework).

``` python
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](#see-also)

- [Devframe Definition](https://devfra.me/guide/devframe-definition)
- [Adapters → CLI (cac)](https://devfra.me/adapters/cac) :`configureCli` , mount-path rules
- [Adapters → Dev](https://devfra.me/adapters/dev)
- [Client](https://devfra.me/guide/client)
- [Agent-Native](https://devfra.me/guide/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.
