{"slug": "standalone-cli-with-devframe", "title": "Standalone CLI with Devframe", "summary": "Devframe released a standalone CLI framework that lets developers ship a single `npx my-tool` command serving a Vue, Nuxt, or React SPA over type-safe RPC, with `build` and `mcp` subcommands. The tool's `build --out-dir` flag produces a self-contained static deploy, and the `mcp` command exposes the tool to AI agents. Devframe's Nuxt helper sets `app.baseURL: './'` and `vite.base: './'`, while Next.js App Router static export requires `output: 'export'`, `assetPrefix: '.'`, `trailingSlash: true`, and `images: { unoptimized: true }`.", "body_md": "# Standalone CLI with Devframe\n\n`npx my-tool` starts a dev server serving a Vue/Nuxt/React SPA over type-safe RPC, plus `build`/` mcp`.\n\n## [What you ship](#what-you-ship)\n\n```\nmy-tool/\n├── bin.mjs                  # shebang + import './dist/cli.mjs'\n├── src/\n│   ├── cli.ts               # defineDevframe + createCac\n│   ├── rpc.ts               # your RPC function definitions\n│   └── data.ts              # your domain-specific logic\n├── app/                     # Nuxt / Vue / React SPA source\n├── dist/\n│   ├── public/              # built SPA output (served at /)\n│   └── cli.mjs              # bundled node entry\n└── package.json\n```\n\n## [Minimal CLI](#minimal-cli)\n\n``` python\nimport process from 'node:process'\nimport { defineDevframe, defineRpcFunction } from 'devframe'\nimport { createCac } from 'devframe/adapters/cac'\nimport { colors as c } from 'devframe/utils/colors'\nimport { resolve } from 'pathe'\n\nconst clientAssets = resolve(import.meta.dirname, '../dist/public')\n\nconst myDevframe = defineDevframe({\n  id: 'my-tool',\n  name: 'My Tool',\n  clientAssets,\n  cli: {\n    command: 'my-tool',\n    port: 7777,\n    portRange: [7777, 9000],\n    open: true, // auth defaults to on; `--open` embeds the current OTP so the tab lands authenticated\n    configure(cli) {\n      cli\n        .option('--config <file>', 'Config file path')\n        .option('--base-path <dir>', 'Base directory for resolution')\n    },\n  },\n  async setup(ctx, { flags }) {\n    const my = ctx.scope('my-tool')\n    my.rpc.register(defineRpcFunction({\n      name: 'get-payload', // -> my-tool:get-payload\n      type: 'query',\n      async handler() {\n        return await loadPayload({\n          configPath: flags.config,\n          basePath: flags.basePath,\n        })\n      },\n    }))\n  },\n})\n\nawait createCac(myDevframe, {\n  onReady({ origin }) {\n    console.log(c.green`My Tool ready at ${origin}`)\n  },\n}).parse(process.argv)\n```\n\nRun:\n\n```\nmy-tool                                     # dev server at http://localhost:7777/\nmy-tool --config ./my.config.mjs\nmy-tool --port 8080 --no-open\nmy-tool build --out-dir dist-static         # self-contained static deploy\nmy-tool build --out-dir dist-static --base /tool/  # …under a custom base\nmy-tool mcp                                 # agent exposure\n```\n\n## [Nuxt SPA setup](#nuxt-spa-setup)\n\nThe Nuxt helper sets `app.baseURL: './'` / `vite.base: './'` and wires `connectDevframe()` into `$rpc` ([Nuxt docs](https://devfra.me/frameworks/nuxt)).\n\n```\nexport default defineNuxtConfig({\n  ssr: false,\n  modules: ['@devframes/nuxt/single'],\n  nitro: {\n    preset: 'static',\n    output: { dir: './dist' }, // matches the definition's clientAssets of ./dist/public\n  },\n})\n```\n\n## [Next.js SPA setup](#nextjs-spa-setup)\n\nFor a Next.js App Router SPA, static export needs:\n\n```\n/** @type {import('next').NextConfig} */\nexport default {\n  output: 'export',\n  assetPrefix: '.',\n  trailingSlash: true,\n  images: { unoptimized: true },\n}\n```\n\n`assetPrefix: '.'` keeps assets base-agnostic; `trailingSlash: true` emits `foo/index.html` for directory-with-index resolution. Copy `next build`'s `out/` to `clientAssets`:\n\n```\n{\n  \"scripts\": {\n    \"build\": \"next build src/client && rm -rf dist/client && mkdir -p dist && cp -r src/client/out dist/client\"\n  }\n}\njs\nimport { fileURLToPath } from 'node:url'\n\ndefineDevframe({\n  id: 'my-tool',\n  clientAssets: fileURLToPath(new URL('../dist/client', import.meta.url)),\n  // …\n})\n```\n\nCall `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).\n\n## [Connecting from the browser side](#connecting-from-the-browser-side)\n\nWith the Nuxt helper, use `$rpc`:\n\n```\nexport async function fetchPayload() {\n  const { $rpc } = useNuxtApp()\n  return $rpc.call('my-tool:get-payload')\n}\n```\n\nOtherwise call `connectDevframe()`, which auto-resolves the connection descriptor relative to the page, whether dev (WebSocket) or static snapshot:\n\n``` js\nimport { connectDevframe } from 'devframe/client'\n\nconst my = (await connectDevframe()).scope('my-tool')\nconst payload = await my.rpc.call('get-payload')\n```\n\n## [Typed CLI flags](#typed-cli-flags)\n\nDeclare tool flags with any [Standard Schema](https://standardschema.dev/) validator (valibot/zod/arktype), validated at parse and typed at the call site:\n\n``` python\nimport type { InferCliFlags } from 'devframe/adapters/cac'\nimport { defineDevframe } from 'devframe'\nimport { defineCliFlags } from 'devframe/adapters/cac'\nimport * as v from 'valibot' // npm i valibot\n\nconst appFlags = defineCliFlags({\n  depth: v.pipe(v.number(), v.integer()),\n  config: v.optional(v.string()),\n  verbose: v.optional(v.boolean()),\n})\n\ndefineDevframe({\n  id: 'my-tool',\n  name: 'My Tool',\n  clientAssets,\n  cli: {\n    flags: appFlags,\n  },\n  setup(ctx, info) {\n    const flags = info.flags as InferCliFlags<typeof appFlags>\n    flags.depth // number\n    flags.config // string | undefined\n  },\n})\n```\n\nBooleans 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.\n\n## [Snapshot queries for static builds](#snapshot-queries-for-static-builds)\n\nFor an RPC function returning one payload per build, set `snapshot: true`; the build adapter runs the handler once, baking the result in:\n\n```\ndefineRpcFunction({\n  name: 'my-tool:get-payload',\n  type: 'query',\n  snapshot: true,\n  handler() {\n    return scanPackages(flags.root)\n  },\n})\n```\n\nIt's the no-args fallback for any deployed `rpc.call('my-tool:get-payload', …)`; a normal `query` in dev.\n\n## [On-disk caching](#on-disk-caching)\n\nPersistence 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`.\n\n``` js\nimport { resolve } from 'pathe'\nimport { createStorage } from 'unstorage'\nimport fsDriver from 'unstorage/drivers/fs'\n\nconst cache = createStorage({\n  driver: fsDriver({\n    base: resolve(process.cwd(), 'node_modules/.cache/my-tool'),\n  }),\n})\n\ndefineDevframe({\n  id: 'my-tool',\n  name: 'My Tool',\n  async setup(ctx) {\n    ctx.scope('my-tool').rpc.register(defineRpcFunction({\n      name: 'get-npm-meta', // -> my-tool:get-npm-meta\n      type: 'query',\n      async handler(spec: string) {\n        return (await cache.getItem(spec))\n          ?? await fetchAndCache(spec, cache)\n      },\n    }))\n  },\n})\n```\n\n## [Live-reload on config changes](#live-reload-on-config-changes)\n\nFilesystem watching is your tool's job: wire chokidar, signal the browser side via shared state.\n\n```\ndefineDevframe({\n  id: 'my-tool',\n  name: 'My Tool',\n  async setup(ctx, { flags }) {\n    const my = ctx.scope('my-tool')\n    my.rpc.register(defineRpcFunction({\n      name: 'get-payload', // -> my-tool:get-payload\n      type: 'query',\n      cacheable: true,\n      handler: () => loadPayload({ configPath: flags.config }),\n    }))\n\n    if (ctx.mode === 'dev') {\n      const version = await my.rpc.sharedState('version', { initialValue: { ts: 0 } })\n      const { default: chokidar } = await import('chokidar')\n      const watcher = chokidar.watch(flags.config ?? [], { ignoreInitial: true })\n      watcher.on('change', () => {\n        version.mutate((draft) => {\n          draft.ts = Date.now()\n        })\n      })\n    }\n  },\n})\n```\n\nOn the browser side:\n\n``` js\nconst my = (await connectDevframe()).scope('my-tool')\nconst version = await my.rpc.sharedState('version')\nversion.on('updated', () => fetchPayload().then(setData))\n```\n\n## [Use your own CLI framework](#use-your-own-cli-framework)\n\nOwn 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).\n\n``` python\nimport process from 'node:process'\nimport { Command } from 'commander'\nimport { defineDevframe } from 'devframe'\nimport { createBuild } from 'devframe/adapters/build'\nimport { createDevServer } from 'devframe/adapters/dev'\n\nconst myDevframe = defineDevframe({\n  id: 'my-tool',\n  name: 'My Tool',\n  clientAssets: './dist/public',\n  cli: { port: 7777 },\n  setup(ctx, { flags }) { /* ... */ },\n})\n\nconst program = new Command('my-tool')\n\nprogram\n  .command('dev', { isDefault: true })\n  .option('-p, --port <port>', 'Port', '7777')\n  .option('--config <file>', 'Config file path')\n  .action(async (opts) => {\n    const handle = await createDevServer(myDevframe, {\n      port: Number(opts.port),\n      flags: { config: opts.config },\n      onReady: ({ origin }) => console.log(`Ready at ${origin}`),\n    })\n    process.on('SIGINT', () => handle.close().then(() => process.exit(0)))\n  })\n\nprogram\n  .command('build')\n  .option('--out-dir <dir>', 'Output directory', 'dist-static')\n  .action(opts => createBuild(myDevframe, { outDir: opts.outDir }))\n\nawait program.parseAsync()\n```\n\n`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`.\n\n## [See also](#see-also)\n\n- [Devframe Definition](https://devfra.me/guide/devframe-definition)\n- [Adapters → CLI (cac)](https://devfra.me/adapters/cac) :`configureCli` , mount-path rules\n- [Adapters → Dev](https://devfra.me/adapters/dev)\n- [Client](https://devfra.me/guide/client)\n- [Agent-Native](https://devfra.me/guide/agent-native)\n\nStructured Diagnostics\n\nctx.diagnostics is a thin layer over nostics for author-defined coded diagnostics, each with a stable code, docs URL, and structured payload.\n\nClient\n\nThe 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.", "url": "https://wpnews.pro/news/standalone-cli-with-devframe", "canonical_source": "https://devfra.me/guide/standalone-cli", "published_at": "2026-09-23 16:30:29.670806+00:00", "updated_at": "2026-09-23 16:30:31.642503+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents", "agent-protocols"], "entities": ["Devframe", "Nuxt", "Vue", "React", "Next.js", "createCac", "defineDevframe", "connectDevframe"], "alternates": {"html": "https://wpnews.pro/news/standalone-cli-with-devframe", "markdown": "https://wpnews.pro/news/standalone-cli-with-devframe.md", "text": "https://wpnews.pro/news/standalone-cli-with-devframe.txt", "jsonld": "https://wpnews.pro/news/standalone-cli-with-devframe.jsonld"}}