@briancray/belte 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/bin/belte.ts +25 -12
  2. package/package.json +2 -1
  3. package/src/appEntry.ts +124 -0
  4. package/src/belteResolverPlugin.ts +217 -194
  5. package/src/build.ts +6 -67
  6. package/src/buildCli.ts +36 -63
  7. package/src/buildDisconnected.ts +127 -0
  8. package/src/bundleApp.ts +123 -0
  9. package/src/bundleDisconnectedEntry.ts +17 -0
  10. package/src/cliEntry.ts +3 -9
  11. package/src/compile.ts +4 -15
  12. package/src/controlServerWorker.ts +261 -0
  13. package/src/dedupeSveltePlugin.ts +66 -0
  14. package/src/discoveryEntry.ts +12 -11
  15. package/src/lib/browser/cache.ts +3 -6
  16. package/src/lib/browser/page.svelte.ts +19 -21
  17. package/src/lib/browser/socketChannel.ts +11 -1
  18. package/src/lib/browser/types/Pages.ts +1 -1
  19. package/src/lib/bundle/BundleMenu.ts +11 -0
  20. package/src/lib/bundle/BundleMenuItem.ts +24 -0
  21. package/src/lib/bundle/BundleWindow.ts +20 -0
  22. package/src/lib/bundle/bindConnectedFlag.ts +29 -0
  23. package/src/lib/bundle/bindRequestNavigate.ts +31 -0
  24. package/src/lib/bundle/buildWebviewLib.ts +111 -0
  25. package/src/lib/bundle/disconnected.css +9 -0
  26. package/src/lib/bundle/disconnected.svelte +192 -0
  27. package/src/lib/bundle/ensureWebviewLib.ts +20 -0
  28. package/src/lib/bundle/exitWithParent.ts +28 -0
  29. package/src/lib/bundle/findFreePort.ts +14 -0
  30. package/src/lib/bundle/infoPlist.ts +46 -0
  31. package/src/lib/bundle/installMacMenu.ts +39 -0
  32. package/src/lib/bundle/listenLocalControlServer.ts +19 -0
  33. package/src/lib/bundle/native/belteMenu.mm +298 -0
  34. package/src/lib/bundle/native/webview.h +4557 -0
  35. package/src/lib/bundle/onMenu.ts +26 -0
  36. package/src/lib/bundle/openWebview.ts +81 -0
  37. package/src/lib/bundle/pngToIcns.ts +47 -0
  38. package/src/lib/bundle/probeBelteServer.ts +34 -0
  39. package/src/lib/bundle/resolveServerBinary.ts +12 -0
  40. package/src/lib/bundle/resolveWebviewLib.ts +51 -0
  41. package/src/lib/bundle/serverBinaryFilename.ts +8 -0
  42. package/src/lib/bundle/stableLocalPort.ts +19 -0
  43. package/src/lib/bundle/waitForServer.ts +23 -0
  44. package/src/lib/bundle/webviewBuildRevision.ts +9 -0
  45. package/src/lib/bundle/webviewCachePath.ts +23 -0
  46. package/src/lib/bundle/webviewLibName.ts +11 -0
  47. package/src/lib/bundle/webviewVersion.ts +7 -0
  48. package/src/lib/cli/createClient.ts +34 -36
  49. package/src/lib/cli/printHelp.ts +45 -2
  50. package/src/lib/cli/runCli.ts +12 -3
  51. package/src/lib/mcp/createMcpResourceServer.ts +1 -1
  52. package/src/lib/mcp/dispatchMcpRequest.ts +53 -73
  53. package/src/lib/server/AppModule.ts +2 -2
  54. package/src/lib/server/cli/handleCliDownload.ts +4 -5
  55. package/src/lib/server/cli/handleCliInstall.ts +17 -0
  56. package/src/lib/server/error.ts +23 -9
  57. package/src/lib/server/json.ts +5 -5
  58. package/src/lib/server/jsonl.ts +10 -5
  59. package/src/lib/server/prompts/definePrompt.ts +6 -6
  60. package/src/lib/server/prompts/renderPromptTemplate.ts +16 -0
  61. package/src/lib/server/prompts/types/Prompt.ts +8 -9
  62. package/src/lib/server/prompts/types/PromptOptions.ts +7 -12
  63. package/src/lib/server/prompts/types/PromptRegistryEntry.ts +3 -5
  64. package/src/lib/server/prompts/types/PromptRoutes.ts +4 -4
  65. package/src/lib/server/redirect.ts +13 -8
  66. package/src/lib/server/rpc/defineVerb.ts +4 -3
  67. package/src/lib/server/rpc/findVerbByCommandName.ts +18 -0
  68. package/src/lib/server/rpc/types/RemoteFunction.ts +1 -1
  69. package/src/lib/server/rpc/types/RemoteHandler.ts +4 -0
  70. package/src/lib/server/runtime/acceptsZstd.ts +8 -0
  71. package/src/lib/server/runtime/buildOpenApiSpec.ts +2 -0
  72. package/src/lib/server/runtime/cacheControlForAsset.ts +7 -2
  73. package/src/lib/server/runtime/createAssetHeaderCache.ts +35 -0
  74. package/src/lib/server/runtime/createPublicAssetServer.ts +6 -20
  75. package/src/lib/server/runtime/createServer.ts +50 -58
  76. package/src/lib/server/runtime/registryManifests.ts +33 -15
  77. package/src/lib/server/runtime/types/RequestStore.ts +2 -3
  78. package/src/lib/server/runtime/withResponseDefaults.ts +24 -0
  79. package/src/lib/server/sockets/createSocketDispatcher.ts +10 -7
  80. package/src/lib/server/sse.ts +10 -5
  81. package/src/lib/shared/cacheControlValues.ts +10 -2
  82. package/src/lib/shared/canonicalJson.ts +1 -5
  83. package/src/lib/shared/createCacheStore.ts +29 -20
  84. package/src/lib/shared/exitOnBuildFailure.ts +17 -0
  85. package/src/lib/shared/fileStem.ts +9 -0
  86. package/src/lib/shared/jsonSchemaForPromptArguments.ts +29 -0
  87. package/src/lib/shared/keyForRemoteCall.ts +7 -5
  88. package/src/lib/shared/parsePromptMarkdown.ts +34 -0
  89. package/src/lib/shared/promptNameForFile.ts +5 -5
  90. package/src/lib/shared/subscribableFromResponse.ts +104 -215
  91. package/src/lib/shared/types/PromptArgument.ts +12 -0
  92. package/src/lib/shared/writeRoutesDts.ts +5 -7
  93. package/src/serverBuildPlugins.ts +25 -0
  94. package/src/serverEntry.ts +4 -0
  95. package/template/package.json +2 -1
  96. package/src/lib/server/prompt.ts +0 -30
  97. package/src/lib/server/prompts/types/PromptMessage.ts +0 -10
  98. package/src/lib/shared/preparePromptModule.ts +0 -36
@@ -0,0 +1,26 @@
1
+ /*
2
+ Subscribes to bundle menu clicks. Each custom menu item declared in the bundle
3
+ window config dispatches a `belte:menu` CustomEvent into the page when clicked;
4
+ this registers `handler`, called with the item's `emit` name. Returns an
5
+ unsubscribe function, so it drops straight into a Svelte `$effect`:
6
+
7
+ $effect(() =>
8
+ onMenu((name) => {
9
+ if (name === 'reload') location.reload()
10
+ }),
11
+ )
12
+
13
+ Inert during SSR and in a plain browser tab — `$effect` only runs client-side,
14
+ the native menu that fires the event exists only in the bundled desktop app,
15
+ and `window` is guarded so importing the module never assumes a DOM.
16
+ */
17
+ export function onMenu(handler: (name: string) => void): () => void {
18
+ if (typeof window === 'undefined') {
19
+ return () => {}
20
+ }
21
+ function listener(event: Event) {
22
+ handler((event as CustomEvent<{ name: string }>).detail.name)
23
+ }
24
+ window.addEventListener('belte:menu', listener)
25
+ return () => window.removeEventListener('belte:menu', listener)
26
+ }
@@ -0,0 +1,81 @@
1
+ import { dlopen, FFIType, type Pointer } from 'bun:ffi'
2
+ import type { BundleMenu } from './BundleMenu.ts'
3
+ import { installMacMenu } from './installMacMenu.ts'
4
+ import { resolveWebviewLib } from './resolveWebviewLib.ts'
5
+
6
+ // WEBVIEW_HINT_NONE — the window is freely resizable (the only hint we need).
7
+ const WEBVIEW_HINT_NONE = 0
8
+
9
+ /*
10
+ Encodes a string as a NUL-terminated UTF-8 buffer for the C ABI. bun:ffi
11
+ passes a TypedArray to a `ptr` argument as a raw pointer, and the webview
12
+ C functions expect NUL-terminated `const char *`.
13
+ */
14
+ function cString(value: string): Uint8Array {
15
+ return new TextEncoder().encode(`${value}\0`)
16
+ }
17
+
18
+ /*
19
+ Opens a native OS webview window pointed at `url` and blocks until the
20
+ user closes it. This drives the platform UI run loop (WebKit on macOS,
21
+ WebView2 on Windows, WebKitGTK on Linux) via FFI against the webview C
22
+ library — no Chromium is bundled. Because `webview_run` enters a blocking
23
+ native event loop on the calling thread, the belte server must already be
24
+ running in a separate process; this call owns the main thread until the
25
+ window closes, then destroys the handle and releases the library.
26
+ */
27
+ export async function openWebview({
28
+ url,
29
+ title,
30
+ width = 1024,
31
+ height = 768,
32
+ menu,
33
+ fileMenu,
34
+ onWindow,
35
+ }: {
36
+ url: string
37
+ title: string
38
+ width?: number
39
+ height?: number
40
+ menu?: BundleMenu[]
41
+ // The File menu, inserted before Edit — the launcher's Start/Disconnect.
42
+ fileMenu?: BundleMenu
43
+ /*
44
+ Hands back the window handle once it exists, before the run loop blocks the
45
+ thread. The launcher forwards it to its control-server worker so the worker
46
+ can navigate the window from off-thread (e.g. bounce back to the connect
47
+ screen when the connected server dies).
48
+ */
49
+ onWindow?: (handle: Pointer | null) => void
50
+ }): Promise<void> {
51
+ const libPath = await resolveWebviewLib()
52
+ const { symbols, close } = dlopen(libPath, {
53
+ webview_create: { args: [FFIType.i32, FFIType.ptr], returns: FFIType.ptr },
54
+ webview_set_title: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.void },
55
+ webview_set_size: {
56
+ args: [FFIType.ptr, FFIType.i32, FFIType.i32, FFIType.i32],
57
+ returns: FFIType.void,
58
+ },
59
+ webview_navigate: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.void },
60
+ webview_run: { args: [FFIType.ptr], returns: FFIType.void },
61
+ webview_destroy: { args: [FFIType.ptr], returns: FFIType.void },
62
+ })
63
+
64
+ // The second arg is an optional parent window handle; null means a fresh window.
65
+ const handle = symbols.webview_create(0, null)
66
+ symbols.webview_set_title(handle, cString(title))
67
+ symbols.webview_set_size(handle, width, height, WEBVIEW_HINT_NONE)
68
+ /*
69
+ Install the macOS menu bar (no-op off macOS) after the application exists
70
+ but before the run loop starts, so Quit and the Edit shortcuts work — the
71
+ upstream webview omits the menu entirely — plus the bundle's custom menus.
72
+ */
73
+ installMacMenu(libPath, handle, title, menu, fileMenu)
74
+ onWindow?.(handle)
75
+
76
+ symbols.webview_navigate(handle, cString(url))
77
+ // Blocks here, running the native UI loop, until the window is closed.
78
+ symbols.webview_run(handle)
79
+ symbols.webview_destroy(handle)
80
+ close()
81
+ }
@@ -0,0 +1,47 @@
1
+ import { log } from '../shared/log.ts'
2
+
3
+ /*
4
+ The conventional macOS `.iconset` contents — each variant is a square PNG
5
+ at the named pixel size. `iconutil` packs a directory of exactly these
6
+ into a multi-resolution `.icns`. @2x entries are the retina variants.
7
+ */
8
+ const ICONSET_VARIANTS = [
9
+ { name: 'icon_16x16.png', size: 16 },
10
+ { name: 'icon_16x16@2x.png', size: 32 },
11
+ { name: 'icon_32x32.png', size: 32 },
12
+ { name: 'icon_32x32@2x.png', size: 64 },
13
+ { name: 'icon_128x128.png', size: 128 },
14
+ { name: 'icon_128x128@2x.png', size: 256 },
15
+ { name: 'icon_256x256.png', size: 256 },
16
+ { name: 'icon_256x256@2x.png', size: 512 },
17
+ { name: 'icon_512x512.png', size: 512 },
18
+ { name: 'icon_512x512@2x.png', size: 1024 },
19
+ ]
20
+
21
+ /*
22
+ Converts a PNG into a macOS `.icns` using the system `sips` + `iconutil`
23
+ tools, which ship with macOS. `sips` resizes the source into each iconset
24
+ variant; `iconutil` packs the iconset directory into the `.icns`. Returns
25
+ true on success. On any failure (tools missing, unreadable source) it logs
26
+ a warning and returns false so the bundle still completes without an icon
27
+ rather than aborting the whole build.
28
+ */
29
+ export async function pngToIcns(pngPath: string, outPath: string): Promise<boolean> {
30
+ const iconset = `${outPath}.iconset`
31
+ try {
32
+ await Bun.$`mkdir -p ${iconset}`.quiet()
33
+ await Promise.all(
34
+ ICONSET_VARIANTS.map(({ name, size }) =>
35
+ Bun.$`sips -z ${size} ${size} ${pngPath} --out ${`${iconset}/${name}`}`.quiet(),
36
+ ),
37
+ )
38
+ await Bun.$`iconutil -c icns ${iconset} -o ${outPath}`.quiet()
39
+ return true
40
+ } catch (error) {
41
+ log.warn(`could not convert ${pngPath} to .icns — bundling without an icon`)
42
+ log.error(error)
43
+ return false
44
+ } finally {
45
+ await Bun.$`rm -rf ${iconset}`.quiet()
46
+ }
47
+ }
@@ -0,0 +1,34 @@
1
+ // The identity shape a belte server returns from GET /__belte/identity.
2
+ export type BelteIdentity = { name: string; version: string }
3
+
4
+ /*
5
+ Confirms a URL points at a belte server before the launcher navigates the app
6
+ window there, by fetching its unauthenticated identity endpoint. Returns the
7
+ server's identity on success, or undefined when nothing belte answers — a network
8
+ error, the wrong port, or a non-belte page (a bare 403/404, a different app). The
9
+ endpoint bypasses the app's own middleware, so an auth-guarded belte app still
10
+ verifies here even though its pages would later redirect to a login.
11
+ */
12
+ export async function probeBelteServer(target: string): Promise<BelteIdentity | undefined> {
13
+ try {
14
+ const base = target.replace(/\/+$/, '')
15
+ const response = await fetch(`${base}/__belte/identity`, {
16
+ headers: { accept: 'application/json' },
17
+ signal: AbortSignal.timeout(5000),
18
+ })
19
+ if (!response.ok) {
20
+ return undefined
21
+ }
22
+ const body = (await response.json()) as {
23
+ belte?: boolean
24
+ name?: string
25
+ version?: string
26
+ }
27
+ if (body.belte !== true) {
28
+ return undefined
29
+ }
30
+ return { name: body.name ?? 'belte app', version: body.version ?? '0.0.0' }
31
+ } catch {
32
+ return undefined
33
+ }
34
+ }
@@ -0,0 +1,12 @@
1
+ import { dirname, join } from 'node:path'
2
+ import { serverBinaryFilename } from './serverBinaryFilename.ts'
3
+
4
+ /*
5
+ Locates the embedded server binary that ships beside the launcher inside a
6
+ bundle. The launcher's own path is `process.execPath` (the compiled binary
7
+ itself), so the server sits in the same directory — true for both the
8
+ flat-directory layout and a macOS `.app`'s `Contents/MacOS/`.
9
+ */
10
+ export function resolveServerBinary(): string {
11
+ return join(dirname(process.execPath), serverBinaryFilename())
12
+ }
@@ -0,0 +1,51 @@
1
+ import { dirname, join } from 'node:path'
2
+ import { webviewCachePath } from './webviewCachePath.ts'
3
+ import { webviewLibName } from './webviewLibName.ts'
4
+
5
+ /*
6
+ Locates the native webview shared library to load over FFI, without ever
7
+ compiling — this runs in the compiled launcher too, where no toolchain is
8
+ present. Resolution order:
9
+
10
+ 1. BELTE_WEBVIEW_LIB — explicit path, the escape hatch for any layout.
11
+ 2. inside a bundle — beside the launcher binary (flat layout) or in
12
+ `../Frameworks` (macOS `.app`), so a shipped bundle is self-contained.
13
+ 3. belte's own build cache — the library compiled from the vendored
14
+ header by buildWebviewLib (populated at build time via ensureWebviewLib).
15
+
16
+ belte ships the vendored source rather than a prebuilt binary, so the
17
+ toolchain path (`belte bundle`) calls ensureWebviewLib to build-on-miss;
18
+ this resolver only reports what already exists. Throws with
19
+ guidance when nothing resolves rather than letting dlopen fail opaquely.
20
+ */
21
+ export async function resolveWebviewLib(cwd: string = process.cwd()): Promise<string> {
22
+ const fromEnv = process.env.BELTE_WEBVIEW_LIB
23
+ if (fromEnv) {
24
+ return fromEnv
25
+ }
26
+
27
+ const libName = webviewLibName()
28
+
29
+ /*
30
+ Bundle-relative candidates. In dev `process.execPath` is the `bun`
31
+ binary, so these miss and we fall through to the build cache; in a
32
+ shipped bundle the launcher's own directory holds the lib.
33
+ */
34
+ const binDir = dirname(process.execPath)
35
+ const bundledCandidates = [join(binDir, libName), join(binDir, '..', 'Frameworks', libName)]
36
+ for (const candidate of bundledCandidates) {
37
+ if (await Bun.file(candidate).exists()) {
38
+ return candidate
39
+ }
40
+ }
41
+
42
+ const cached = webviewCachePath()
43
+ if (await Bun.file(cached).exists()) {
44
+ return cached
45
+ }
46
+
47
+ throw new Error(
48
+ '[belte] no native webview library found. Run `belte bundle` to ' +
49
+ 'build it from the vendored source, or set BELTE_WEBVIEW_LIB to a prebuilt one.',
50
+ )
51
+ }
@@ -0,0 +1,8 @@
1
+ /*
2
+ Filename of the embedded server binary that ships beside the launcher
3
+ inside a bundle. Both the bundler (which writes it) and the launcher
4
+ (which spawns it) derive the name here so they can't drift apart.
5
+ */
6
+ export function serverBinaryFilename(platform: NodeJS.Platform = process.platform): string {
7
+ return platform === 'win32' ? 'server.exe' : 'server'
8
+ }
@@ -0,0 +1,19 @@
1
+ /*
2
+ Derives a deterministic localhost port from the program name so the connect
3
+ screen's origin (and thus its localStorage) stays stable across launches — a
4
+ remembered server URL survives a relaunch only if the page is reloaded from the
5
+ same origin. Hashes the name with FNV-1a (32-bit) and maps it into the
6
+ dynamic/private range (49152–65535). The caller probes availability and falls
7
+ back to a random free port on collision, so determinism is a best effort, not a
8
+ guarantee.
9
+ */
10
+ export function stableLocalPort(programName: string): number {
11
+ // FNV-1a 32-bit: offset basis 2166136261, prime 16777619. Math.imul keeps
12
+ // the multiply in 32-bit space; `>>> 0` reads the result back as unsigned.
13
+ let hash = 0x811c9dc5
14
+ for (const character of programName) {
15
+ hash ^= character.charCodeAt(0)
16
+ hash = Math.imul(hash, 0x01000193)
17
+ }
18
+ return 49152 + ((hash >>> 0) % 16384)
19
+ }
@@ -0,0 +1,23 @@
1
+ /*
2
+ Polls an HTTP URL until it answers (any status) or the deadline passes.
3
+ The spawned server child binds asynchronously, so the launcher can't open
4
+ the webview until a request round-trips. A connection refusal throws and
5
+ is swallowed; once Bun.serve is listening the fetch resolves and we
6
+ return. Throws on timeout so the launcher can report a failed boot rather
7
+ than open a blank window.
8
+ */
9
+ export async function waitForServer(
10
+ url: string,
11
+ { timeoutMs = 10_000, intervalMs = 50 }: { timeoutMs?: number; intervalMs?: number } = {},
12
+ ): Promise<void> {
13
+ const deadline = Bun.nanoseconds() + timeoutMs * 1e6
14
+ while (Bun.nanoseconds() < deadline) {
15
+ try {
16
+ await fetch(url)
17
+ return
18
+ } catch {
19
+ await Bun.sleep(intervalMs)
20
+ }
21
+ }
22
+ throw new Error(`[belte] server did not become ready at ${url} within ${timeoutMs}ms`)
23
+ }
@@ -0,0 +1,9 @@
1
+ /*
2
+ Revision of belte's own contribution to the compiled webview library — the
3
+ native shim sources linked in beside the vendored header (e.g. belteMenu.mm)
4
+ and the flags buildWebviewLib compiles them with. It participates in the build
5
+ cache key alongside the upstream version, so changing belte's native build
6
+ selects a fresh cache path and bypasses any library built before the change.
7
+ Bump this whenever the shim sources or their compile invocation change.
8
+ */
9
+ export const webviewBuildRevision = 8
@@ -0,0 +1,23 @@
1
+ import { join } from 'node:path'
2
+ import { webviewBuildRevision } from './webviewBuildRevision.ts'
3
+ import { webviewLibName } from './webviewLibName.ts'
4
+ import { webviewVersion } from './webviewVersion.ts'
5
+
6
+ /*
7
+ Absolute path where the locally built webview library is cached. belte
8
+ compiles the vendored `native/webview.h` once per host and reuses the
9
+ result; both buildWebviewLib (the writer) and resolveWebviewLib (a reader)
10
+ derive the location here so they never drift.
11
+
12
+ The cache sits next to the vendored source inside the belte package, so it
13
+ is shared across every project on the machine that uses this belte install
14
+ and survives independently of any consumer's `cwd`. Namespacing by
15
+ platform + arch + upstream version keeps a single cache correct across
16
+ architectures and makes a header bump — or a belte native-build bump — select
17
+ a fresh path automatically.
18
+ */
19
+ export function webviewCachePath(): string {
20
+ const nativeDir = new URL('./native', import.meta.url).pathname
21
+ const key = `${process.platform}-${process.arch}-${webviewVersion}-${webviewBuildRevision}`
22
+ return join(nativeDir, '.cache', key, webviewLibName())
23
+ }
@@ -0,0 +1,11 @@
1
+ import { suffix } from 'bun:ffi'
2
+
3
+ /*
4
+ Native webview shared-library filename for a platform. `suffix` is Bun's
5
+ host shared-library extension (`dylib`/`so`/`dll`). The bundler copies a
6
+ file under this name and the loader looks for it under the same name, so
7
+ both derive it here.
8
+ */
9
+ export function webviewLibName(platform: NodeJS.Platform = process.platform): string {
10
+ return platform === 'win32' ? `webview.${suffix}` : `libwebview.${suffix}`
11
+ }
@@ -0,0 +1,7 @@
1
+ /*
2
+ Upstream `webview/webview` release the vendored `native/webview.h` is taken
3
+ from (https://github.com/webview/webview, MIT). Used to namespace the build
4
+ cache so bumping the header naturally bypasses any previously built library.
5
+ Bump this whenever `native/webview.h` is re-vendored.
6
+ */
7
+ export const webviewVersion = '0.12.0'
@@ -1,10 +1,9 @@
1
+ import { findVerbByCommandName } from '../server/rpc/findVerbByCommandName.ts'
1
2
  import type { HttpVerb } from '../server/rpc/types/HttpVerb.ts'
2
3
  import { verbRegistry } from '../server/rpc/verbRegistry.ts'
3
4
  import { buildRpcRequest } from '../shared/buildRpcRequest.ts'
4
- import { commandNameForUrl } from '../shared/commandNameForUrl.ts'
5
5
  import { decodeResponse } from '../shared/decodeResponse.ts'
6
6
  import type { CliManifest } from './types/CliManifest.ts'
7
- import type { CliManifestEntry } from './types/CliManifestEntry.ts'
8
7
 
9
8
  type AnyApi = Record<string, (args?: unknown) => Promise<unknown>>
10
9
 
@@ -23,8 +22,11 @@ decided at construction:
23
22
 
24
23
  The `manifest` is the bundler-emitted CLI manifest baked into the thin
25
24
  binary. In in-process mode it's optional (registry is the source of
26
- truth). Both can be supplied to support a binary that talks remote by
27
- default but falls back to in-process when APP_URL is unset.
25
+ truth); in remote mode it supplies the method + url per command without
26
+ needing the rpc modules loaded. The mode is chosen solely by whether
27
+ `url` is set — the shipped CLI binary (see runCli) always passes `url`,
28
+ so it runs remote-only; in-process mode is for same-project scripts and
29
+ tests that import this directly without a `url`.
28
30
  */
29
31
  export function createClient<Api extends AnyApi = AnyApi>(opts?: {
30
32
  url?: string
@@ -45,55 +47,45 @@ export function createClient<Api extends AnyApi = AnyApi>(opts?: {
45
47
  if (entry) {
46
48
  return { method: entry.method, url: entry.url }
47
49
  }
48
- for (const value of verbRegistry.values()) {
49
- if (commandNameForUrl(value.remote.url) === name) {
50
- return { method: value.remote.method, url: value.remote.url }
51
- }
52
- }
53
- return undefined
50
+ const found = findVerbByCommandName(name)
51
+ return found ? { method: found.remote.method, url: found.remote.url } : undefined
54
52
  }
55
53
 
56
- async function callRemote(
54
+ /*
55
+ Single call path for both modes — only the base URL and how the Request
56
+ is dispatched differ. Remote mode fetches over the network; in-process
57
+ mode looks the verb up in the registry and runs verb.fetch (no hop).
58
+ */
59
+ async function call(
57
60
  method: HttpVerb,
58
61
  path: string,
59
62
  args: unknown,
60
63
  baseUrl: string,
64
+ dispatch: (request: Request) => Promise<Response>,
61
65
  ): Promise<unknown> {
62
66
  const headers = new Headers()
63
67
  if (token) {
64
68
  headers.set('authorization', `Bearer ${token}`)
65
69
  }
66
70
  const request = buildRpcRequest({ method, url: path, args, baseUrl, headers })
67
- const response = await fetch(request)
71
+ const response = await dispatch(request)
68
72
  if (!response.ok) {
69
73
  throw new Error(`${method} ${path} failed: ${response.status} ${response.statusText}`)
70
74
  }
71
75
  return decodeResponse(response)
72
76
  }
73
77
 
74
- async function callInProcess(method: HttpVerb, path: string, args: unknown): Promise<unknown> {
75
- const entry = verbRegistry.get(path)
76
- if (!entry) {
77
- throw new Error(
78
- `RPC ${path} not loaded — import the module first or set APP_URL to use remote mode`,
79
- )
80
- }
81
- const headers = new Headers()
82
- if (token) {
83
- headers.set('authorization', `Bearer ${token}`)
84
- }
85
- const request = buildRpcRequest({
86
- method,
87
- url: path,
88
- args,
89
- baseUrl: 'http://localhost/',
90
- headers,
91
- })
92
- const response = await entry.remote.fetch(request)
93
- if (!response.ok) {
94
- throw new Error(`${method} ${path} failed: ${response.status} ${response.statusText}`)
78
+ // In-process dispatch: resolve the verb from the registry and run its fetch.
79
+ function inProcessDispatch(path: string): (request: Request) => Promise<Response> {
80
+ return (request) => {
81
+ const entry = verbRegistry.get(path)
82
+ if (!entry) {
83
+ throw new Error(
84
+ `RPC ${path} not loaded — import the module first or set APP_URL to use remote mode`,
85
+ )
86
+ }
87
+ return entry.remote.fetch(request)
95
88
  }
96
- return decodeResponse(response)
97
89
  }
98
90
 
99
91
  /*
@@ -116,8 +108,14 @@ export function createClient<Api extends AnyApi = AnyApi>(opts?: {
116
108
  const invoker = resolved
117
109
  ? (args?: unknown) =>
118
110
  url
119
- ? callRemote(resolved.method, resolved.url, args, url)
120
- : callInProcess(resolved.method, resolved.url, args)
111
+ ? call(resolved.method, resolved.url, args, url, fetch)
112
+ : call(
113
+ resolved.method,
114
+ resolved.url,
115
+ args,
116
+ 'http://localhost/',
117
+ inProcessDispatch(resolved.url),
118
+ )
121
119
  : undefined
122
120
  invokerCache.set(prop, invoker)
123
121
  return invoker
@@ -1,4 +1,28 @@
1
1
  import type { CliManifest } from './types/CliManifest.ts'
2
+ import type { CliManifestEntry } from './types/CliManifestEntry.ts'
3
+
4
+ /*
5
+ Compact one-line flag signature for the top-level listing: required
6
+ flags are shown bare, optional flags wrapped in `[ ]`. Booleans drop the
7
+ value placeholder; arrays show `<value...>` to hint repetition. Full
8
+ per-flag types + descriptions live in `<cmd> --help`.
9
+ */
10
+ function flagSignature(jsonSchema: CliManifestEntry['jsonSchema']): string {
11
+ const properties =
12
+ (jsonSchema?.properties as Record<string, { type?: string }> | undefined) ?? {}
13
+ const required = new Set((jsonSchema?.required as string[] | undefined) ?? [])
14
+ return Object.entries(properties)
15
+ .map(([key, value]) => {
16
+ const placeholder =
17
+ value.type === 'boolean'
18
+ ? `--${key}`
19
+ : value.type === 'array'
20
+ ? `--${key} <value...>`
21
+ : `--${key} <${value.type ?? 'value'}>`
22
+ return required.has(key) ? placeholder : `[${placeholder}]`
23
+ })
24
+ .join(' ')
25
+ }
2
26
 
3
27
  /*
4
28
  Top-level help (no subcommand) lists every available command with a
@@ -24,12 +48,27 @@ export function printTopLevelHelp(
24
48
  if (!entry) {
25
49
  continue
26
50
  }
27
- console.log(` ${name.padEnd(20)} ${entry.method} ${entry.url}`)
51
+ /*
52
+ Summary line favours the schema's top-level description (carried
53
+ through by the vendor's JSON Schema conversion); falls back to
54
+ `method url` when the schema has none. The detail line below
55
+ always shows the args, plus method/url when it isn't already the
56
+ summary.
57
+ */
58
+ const description = entry.jsonSchema?.description as string | undefined
59
+ console.log(` ${name.padEnd(20)} ${description ?? `${entry.method} ${entry.url}`}`)
60
+ const signature = flagSignature(entry.jsonSchema)
61
+ const detail = [description && `${entry.method} ${entry.url}`, signature]
62
+ .filter(Boolean)
63
+ .join(' ')
64
+ if (detail) {
65
+ console.log(` ${' '.repeat(20)} ${detail}`)
66
+ }
28
67
  }
29
68
  console.log(`\n --help, -h show this help`)
30
69
  console.log(` <command> --help show help for a specific command`)
31
70
  console.log(`\nenv:`)
32
- console.log(` APP_URL remote server URL (unset → in-process)`)
71
+ console.log(` APP_URL remote server URL (required)`)
33
72
  console.log(` APP_TOKEN sent as Authorization: Bearer <value>`)
34
73
  if (footer.trim()) {
35
74
  console.log('')
@@ -46,6 +85,10 @@ export function printCommandHelp(programName: string, name: string, manifest: Cl
46
85
  console.log(`usage: ${programName} ${name} [--flags]\n`)
47
86
  console.log(` ${entry.method} ${entry.url}\n`)
48
87
  const schema = entry.jsonSchema
88
+ const commandDescription = schema?.description as string | undefined
89
+ if (commandDescription) {
90
+ console.log(`${commandDescription}\n`)
91
+ }
49
92
  const properties =
50
93
  (schema?.properties as
51
94
  | Record<string, { type?: string; description?: string }>
@@ -6,14 +6,17 @@ import type { CliManifest } from './types/CliManifest.ts'
6
6
 
7
7
  /*
8
8
  Top-level CLI driver. Loaded by the standalone binary's entry; expects
9
- the bundler-emitted manifest plus the raw argv tail. Flow:
9
+ the bundler-emitted manifest plus the raw argv tail. The binary is a
10
+ thin remote client — it carries no handler code, so it always talks to a
11
+ running server over HTTP and APP_URL must be set. Flow:
10
12
 
11
13
  1. Read .env next to the binary so APP_URL / APP_TOKEN are picked up
12
14
  for the common install-tarball case.
13
15
  2. Pull the first positional as the subcommand.
14
16
  3. --help and `<cmd> --help` print and exit zero.
15
- 4. Otherwise parse the rest of the argv against the manifest entry's
16
- JSON Schema and dispatch via createClient.
17
+ 4. Require APP_URL before dispatching a command.
18
+ 5. Otherwise parse the rest of the argv against the manifest entry's
19
+ JSON Schema and dispatch via createClient against APP_URL.
17
20
 
18
21
  Streaming responses aren't a thing at this layer yet — every RPC tool
19
22
  goes through decodeResponse (text/JSON). Streaming verbs (jsonl/sse)
@@ -62,6 +65,12 @@ export async function runCli({
62
65
  }
63
66
 
64
67
  const appUrl = process.env.APP_URL
68
+ if (!appUrl) {
69
+ console.error(
70
+ `${programName}: APP_URL is not set — the cli talks to a running server, so point it at one (e.g. APP_URL=http://localhost:3000)`,
71
+ )
72
+ return 1
73
+ }
65
74
  const appToken = process.env.APP_TOKEN
66
75
  const client = createClient({ url: appUrl, token: appToken, manifest })
67
76
 
@@ -89,7 +89,7 @@ export function createMcpResourceServer({
89
89
  if (!compressed) {
90
90
  return undefined
91
91
  }
92
- return contentsFor(relativePath, Bun.zstdDecompressSync(compressed))
92
+ return contentsFor(relativePath, await Bun.zstdDecompress(compressed))
93
93
  }
94
94
  const file = Bun.file(`${resourcesDir}/${relativePath}`)
95
95
  if (!(await file.exists())) {