CLI + Plugin
Ship one codebase as both an npx CLI and a Kunkun plugin — the shared-core pattern, with two real case studies.
The most capable Kunkun extensions — a terminal, a disk scanner — are also useful outside the launcher, as a standalone npx tool. Kunkun's portable-core architecture means you don't have to choose: the same engine can be driven by a CLI host and a Kunkun plugin host from one codebase.
This page is the pattern and two real case studies (kkterminal, space-lens) — both live in extensions/ as git submodules, so their commit history shows the exact order they were built.
Should you go dual-target?
Start simple. Most extensions should NOT do this.
Dual-targeting adds a monorepo, a transport boundary, and a second host to maintain. Only reach for it when the tool has real standalone value and a non-trivial engine worth reusing.
| Go plugin-only when… | Go dual-target when… |
|---|---|
| The UI is the product | The engine is valuable without Kunkun's UI |
| Little logic behind the UI | A substantial, transport-agnostic core exists |
| No demand for a terminal/CI/server use | Users want npx yourtool or a browser build too |
| One person, fast iteration | You can maintain a small monorepo |
If you're unsure, build a normal plugin first. You can extract a core later — that's exactly how the case studies below grew.
The pattern: core → adapter → host
Three layers, with a hard rule: the core imports no transport and no host.
- Core — a plain TypeScript contract (
interface YourToolAPI) plus domain logic. No@kunkunsh/sdk, no WebSocket, no kkrpc. - Adapter — implements the contract against the OS (SSH2, node-pty, native bindings). Still transport-agnostic: it returns the same API whether called from a CLI, a browser, or Kunkun.
- Host composition roots — thin entry points that wrap the adapter in a transport:
- the CLI serves it over HTTP/WebSocket (so a browser UI can connect), and
- the Kunkun plugin exposes it with
exposeBackendover kkrpc.
Because the UI talks to the contract, the same SvelteKit frontend renders under both hosts.
The seam, in code
The whole trick is that both hosts consume the identical engine and differ only in how they expose it.
// apps/cli/src/bin.ts — CLI composition root
import { createNodeEngine } from "@yourtool/node";
import { createServer } from "./server.ts"; // Hono + WebSocket
const engine = createNodeEngine({ dataDir });
serve({ fetch: createServer(engine).fetch, port });
// browser connects to http://127.0.0.1:PORT?token=…// apps/kunkun-plugin/src/backend.ts — Kunkun composition root
import { exposeBackend } from "@kunkunsh/sdk/backend";
import { createNodeEngine } from "@yourtool/node";
const engine = createNodeEngine({ dataDir });
exposeBackend<YourToolAPI>(engine.api); // same engine, kkrpc transportThat exposeBackend line is the entire Kunkun-specific surface. Everything above it is reused.
Case study 1 — kkterminal (SSH/SFTP workbench)
An SSH/SFTP client (FinalShell/Termius-style) that runs as a CLI-served web app and a Kunkun custom-view.
Layout
kkterminal/
├── packages/
│ ├── core/ # KkTerminalAPI contract, SessionEvent protocol, validation
│ ├── node/ # SSH2 + node-pty + session manager (implements the contract)
│ └── pty/ # native PTY bindings
└── apps/
├── cli/ # `npx kkterminal` — Hono + WebSocket host
├── web/ # SvelteKit UI (shared)
└── kunkun-plugin/ # Kunkun custom-view (same UI, kkrpc backend)The contract lives in core, transport-free:
// packages/core/src/index.ts
export interface KkTerminalAPI {
hosts: HostAPI; sessions: SessionAPI; sftp: SftpAPI; tunnels: TunnelAPI;
}
export type SessionEvent =
| { type: "session:output"; sessionId: string; chunk: string }
| { type: "session:auth-prompt"; /* … */ };Kunkun host uses a facade over the single engine instance:
// apps/kunkun-plugin/src/backend.ts
import { exposeBackend } from "@kunkunsh/sdk/backend";
import { createNodeKkTerminal } from "@kkterminal/node";
let engine: NodeKkTerminal;
const facade: KkTerminalAPI = {
get sessions() { return engine.api.sessions; },
get sftp() { return engine.api.sftp; },
// …getters defer to the engine, which is created just below
};
const frontend = exposeBackend<KkTerminalAPI, FrontendBridge>(facade);
engine = createNodeKkTerminal({ dataDir }).withFrontendDb(frontend.bridge.db);Note the Kunkun host even backs persistence with the frontend's record store via the bridge — the CLI host uses JSON files instead. Same core, different adapters.
How it was built (submodule history):
The order is the lesson: the engine and CLI came first; the Kunkun host was added last, once the contract was stable.
Case study 2 — space-lens (visual disk scanner)
A disk-usage scanner with a Rust core (napi-rs) consumed by four frontends: a TUI, a CLI web-server, a browser UI, and a Kunkun custom-view.
space-lens/
├── packages/
│ ├── space-lens/ # Rust napi bindings (buildDirectoryTree, getLargestNodes)
│ └── node/ # Node scanner wrapper
└── apps/
├── cli/ # `spacelens` (OpenTUI) + `spacelens-web` (WebSocket server)
├── web/ # SvelteKit UI (shared)
└── kunkun-plugin/ # Kunkun custom-view (Deno backend + path policy)The Kunkun host wraps the shared API to enforce Kunkun's permission scopes — a great example of the host adapter adding safety the CLI doesn't need:
// apps/kunkun-plugin/src/backend.ts
import { exposeBackend } from "@kunkunsh/sdk/backend";
import { createSpaceLensAPI } from "@space-lens/cli/web-service";
import { assertPathsUnderAllowedRoots } from "./path-policy.ts";
function createKunkunSpaceLensAPI(): SpaceLensAPI {
const allowedRoots = normalizeAllowedRoots(readAllowedRootsFromEnv());
const api = createSpaceLensAPI();
return {
...api,
async startScan(options) {
assertPathsUnderAllowedRoots(options.paths, allowedRoots, "scan path");
return api.startScan(options);
},
async executeCleanup() {
throw new Error("Native cleanup disabled; use host-mediated trash instead.");
},
};
}
exposeBackend<SpaceLensAPI>(createKunkunSpaceLensAPI());The CLI allows native deletion; the Kunkun host disables it and defers to the host's system-trash permission. Same engine, host-specific policy at the composition root.
How it was built (submodule history): Rust bindings → disk-usage logic → a compact scanner → a big rewrite → restructure into a monorepo (0.2.0) → add the SvelteKit web GUI → add the Kunkun plugin transport last. Again: core and CLI first, Kunkun host once the contract settled.
Growing into it
You don't design this up front. Start with a normal plugin, then:
- Ship plugin-only. Even a one-file no-view command like
uuid-generatoris a complete extension. - Extract a core when the logic outgrows the UI: move domain logic into a
corepackage with a plain interface and zero host/transport imports. - Add a CLI host (
apps/cli) that serves the core — you now havenpx yourtool. - Add the Kunkun host (
apps/kunkun-plugin) with a singleexposeBackendcomposition root and the shared UI.
Build outputs
Each host builds independently:
- CLI →
dist/bin.mjs(abinentry inpackage.jsonfornpx). - Kunkun plugin →
dist/backend.js+ the copied web UI, with abackendpermission allowing that script (and any native.nodefiles).
The payoff: one engine, one UI, tested once — shipped as a CLI, a web app, and a Kunkun plugin. The Kunkun-specific code is a few dozen lines at the composition root.