| name | software-stack-cli |
| description | TypeScript CLI/TUI/SDK library design on Bun + Hono + zod + vite-plus + vitest. One package hosts a daemon, a CLI, a TUI, and an importable library off the same composition root. Every IO boundary is a port type and every IO-heavy class extracts a pure inner class so the whole thing is mockable end to end. |
| user-invocable | false |
| disable-model-invocation | false |
| metadata | {"author":"shigurenimo","description":"Bun + Hono + zod + vite-plus + vitest ใใผในใฎ TypeScript CLI / TUI / SDK ใฉใคใใฉใช้็บในใญใซใ","dev":true,"tags":["stack"]} |
CLI / TUI / SDK Library
A single package that ships as a CLI, a TUI, and an embeddable library, all driven by the same composition root. The design optimises for full mockability: each IO boundary is exposed as a port type and each IO-heavy class extracts a pure sibling.
Toolchain (vite-plus, vitest, @/* alias, fmt/lint config) lives in software-stack under vp.
Layout
Everything lives under lib/. UI layers (cli/, tui/) depend on runtime/; only runtime/ reaches into the core modules (engine/, channels/, config/, gateway/, env/, daemon/). Core never imports UI.
lib/
โโโ api.ts public exports (barrel)
โโโ index.ts bin entry / argv dispatcher
โโโ error-message.ts shared err.message helper
โโโ cli/ argv โ Hono router โ handler
โโโ tui/ @opentui/react components
โโโ runtime/ composition root
โโโ engine/ core orchestrator
โโโ channels/ channel plugins (chat integrations)
โโโ config/ .<app>/config.json read/write
โโโ daemon/ PID / log / process supervision
โโโ env/ env var loading + zod validation
โโโ gateway/ daemon โ UI/external HTTP IPC
Class names carry the project prefix (<App>Engine, <App>Daemon, <App>ConfigStore); filenames are kebab-case without the prefix because the directory provides the namespace. Type aliases stay unprefixed (ChannelPlugin, ProcessResult, ThreadEntry).
Ports for IO boundaries
Every external SDK, child process, or HTTP boundary gets a *Port type that core code depends on; the real class structurally satisfies the port and tests pass in fakes. Concrete examples: engine/codex/codex-client-port.ts exports CodexClientPort, channels/slack/web-client-port.ts exports WebClientPort, engine/channel-plugin.ts exports the ChannelPlugin and ChannelPluginContext pair.
Pure logic isolation
Inside any IO-heavy class โ <X>Listener, <X>Client, <X>Server โ pull the methods that don't touch fs, spawn, net, or third-party SDKs into a pure sibling. Use <X>Processor for event normalisation (dedup, filtering, mention strip), <X>Protocol for wire framing (JSON-RPC, NDJSON), and standalone helper files for one-off pure logic (daemon/daemon-key.ts, gateway/build-gateway-app.ts). Each pure class is unit-tested with synthetic input and a fake writer; no IO involved.
Composition root
runtime/runtime.ts builds and owns every module. CLI and TUI call <App>Runtime.build({ cwd, env, ... }) and never new core modules directly. Build returns T | Error, never throws.
export class <App>Runtime {
private constructor(private readonly props: Built) {
Object.freeze(this)
}
static build(props): <App>Runtime | Error {
const configStore = new <App>ConfigStore({ cwd: props.cwd })
const config = configStore.load()
if (config instanceof Error) return config
const channelHost = <App>ChannelHost.fromConfig({ config, env: props.env })
if (channelHost instanceof Error) return channelHost
const core = new <App>CoreClient(...)
const engine = new <App>Engine({ core, plugins: channelHost.getPlugins(), ... })
return new <App>Runtime({ configStore, channelHost, core, engine })
}
async start(): Promise<void | Error>
async stop(): Promise<void>
}
Stores
Wrap every file IO surface in a class. Constructor takes cwd (or scope), methods are load() / save() / list() / add() / remove(), no throws, returning T | Error. Pair each store with a sibling <x>-schema.ts that exports zod schemas plus z.infer types. Concrete files: config/config-store.ts plus config/config-schema.ts for app config, and per-domain stores like engine/<x>/<x>-agent-store.ts.
Plugin abstraction
Replaceable behaviour goes through an interface plus a host. The host reads config and constructs plugin instances; the engine sees only the interface, never the concrete plugin types.
type ChannelPlugin = {
readonly name: string
start(ctx: ChannelPluginContext): Promise<void>
stop(): Promise<void>
}
class <App>ChannelHost {
static fromConfig(props): <App>ChannelHost | Error
getPlugins(): readonly ChannelPlugin[]
}
CLI router
cli/utils/to-request.ts converts argv into a POST /<cmd> request with a JSON body of { args, flags }; Hono dispatches to handlers. Each handler reads the body via cli/utils/read-cli-body.ts (zod-parsed). Routes are flat, Remix-style: dots in the filename become path segments, and help text lives in a sibling file.
cli/
โโโ cli-factory.ts Hono factory
โโโ routes/
โ โโโ index.ts
โ โโโ group.help.ts root --help
โ โโโ start.ts + start.help.ts
โ โโโ projects.add.ts + projects.add.help.ts POST /projects/add
โ โโโ channels.list.ts ...
โโโ utils/
โโโ to-request.ts argv โ POST
โโโ read-cli-body.ts
โโโ flag-bool.ts / flag-string.ts
โโโ parse-<param>.ts per-command flag parsers
Daemon
A PID file gates double-start. State directory is ~/.<app>/daemons/<basename>-<sha1[:6]>/. stop sends SIGTERM and leaves logs in place so <app> logs -f can tail -F against the same file. The pure daemon-key.ts (cwd โ state-dir name) is a separate file so tests don't need fs.
daemon/
โโโ <app>-daemon.ts start / stop / status / readPid / isRunning
โโโ daemon-key.ts pure cwd โ state-dir key
Gateway (HTTP IPC)
gateway/build-gateway-app.ts is a pure factory that builds the Hono app from injected deps; gateway/gateway-server.ts is a thin Bun.serve wrapper around it. Tests run app.request(...) directly on the built app โ no port binding, no sockets.
gateway/
โโโ build-gateway-app.ts pure Hono factory
โโโ gateway-server.ts Bun.serve wrapper
โโโ gateway-factory.ts Hono factory + Env type
โโโ gateway-route-deps.ts
โโโ routes/
โโโ index.ts
โโโ health-route.ts
โโโ status-route.ts
โโโ threads/
โโโ list-route.ts
โโโ clear-route.ts
Env
env/
โโโ cli-env-schema.ts zod schema + type
โโโ <app>-env.ts class <App>Env (loadFile, parseCli)
loadFile(path) reads .env-style files without overwriting existing process.env keys. parseCli() runs the schema's safeParse and returns T | Error.
Public API
Re-export every class, every port type, the major types, and the zod schemas from lib/api.ts. Point package.json main, module, types, and exports.types at ./lib/api.ts so consumers can import { LeucoRuntime, LeucoConfigStore, ... } from "<package>".
{
"main": "./lib/api.ts",
"module": "./lib/api.ts",
"types": "./lib/api.ts",
"exports": {
".": {
"types": "./lib/api.ts",
"bun": "./lib/api.ts",
"default": "./lib/api.ts"
},
"./package.json": "./package.json"
}
}
Tests
Run with vp test run and add "test": "vp test run" to package.json scripts. Test by layer: pure processor, protocol, and utility files take synthetic input/output unit tests; stores get a tmpdir and exercise real fs; the channel host runs against a fake config and fake env; the engine runs against a fake core (via the port) plus fake ChannelPlugins; gateway routes are tested with buildGatewayApp({ engine: fakeEngine }) followed by app.request(...); adapters are tested with the relevant *Port faked, asserting the calls made.
Project rules
No throws โ return T | Error. No as (use never-narrowing for exhaustive discriminator unions). No destructuring; use explicit property access (const x = obj.x). One function or one class per file, with the filename matching the class or function name minus the project prefix in kebab-case. Absent values are null, never optional (string | null, never string?). Wire data is always parsed by zod schemas; types come from z.infer.
Migration patterns
When pulling existing code into this layout:
loadEnvFile(path) function โ <App>Env class method
loadConfig / saveConfig fns โ <x>-store.ts class
nested routes <res>/<act>.ts โ flat <res>.<act>.ts
filename <dir>-<class>.ts โ filename <class>.ts (dir as namespace)
spawn + protocol same class โ <X>Client (IO) + <X>Protocol (pure)
new ExternalSdk(token) inline โ static fromXxx() factory + Port type