Skip to main content

Bin Commands

Bin commands are on-demand scripts that MODs expose through the bin field in package.json. Unlike services that run automatically at startup, bin commands run only when explicitly invoked through the HTTP API.

See Package Configuration for how to declare the bin field in your package.json.

Writing a Bin Command Script

Shebang Line

Every TypeScript bin command must start with a shebang line that enables direct execution without a compile step:

#!/usr/bin/env tsx

/// <reference types="node" />

The shebang tells the system to run the file with tsx, which transpiles TypeScript at runtime. The /// <reference types="node" /> directive provides Node.js type definitions (like process.stdin).

warning

Node.js 22 or later is required for tsx. See Installation for setup instructions.

Parsing Input with input.parse

Bin commands receive input via stdin as JSON. The SDK provides input.parse from @hmcs/sdk/commands to read, parse, and validate this input using a Zod schema.

import { z } from "zod";
import { input } from "@hmcs/sdk/commands";

const data = await input.parse(
z.object({
name: z.string(),
count: z.number().default(1),
})
);

console.log(data.name); // validated string
console.log(data.count); // validated number, defaults to 1

input.parse performs three steps:

  1. Read all of stdin as a UTF-8 string
  2. Parse the string as JSON
  3. Validate the parsed object against the Zod schema

If any step fails, it throws a StdinParseError (see Error Handling below).

note

@hmcs/sdk/commands is a separate entry point — it is not re-exported from the main @hmcs/sdk package. This is intentional because it uses Node.js APIs (process.stdin) that are not available in browser environments like WebViews.

If you need the raw stdin string without JSON parsing or validation, use input.read instead:

import { input } from "@hmcs/sdk/commands";

const raw = await input.read();

Output Conventions

Bin commands communicate results through stdout and stderr:

StreamPurposeFormat
stdoutCommand output (results, data)JSON recommended
stderrErrors and diagnostic messagesFree-form text

The SDK provides helper functions for structured output. Import them from @hmcs/sdk/commands:

import { output } from "@hmcs/sdk/commands";

Success output — Write a JSON result to stdout and exit with code 0:

output.succeed({ speakers: [...], count: 5 });

Success exit (no output) — Exit with code 0 without writing to stdout. Useful for commands that perform a side effect like opening a UI:

output.succeed();

Error output — Write a structured error to stderr and exit with a non-zero code:

output.fail("NOT_FOUND", "Speaker 99 does not exist");
// exits with code 1 (default)

output.fail("TIMEOUT", "Request timed out", 2);
// exits with code 2

The output.fail function writes a JSON object with code and message fields to stderr:

{"code":"NOT_FOUND","message":"Speaker 99 does not exist"}

If you need to write output without exiting (e.g., intermediate progress), use the non-exit variants:

import { output } from "@hmcs/sdk/commands";

output.write({ progress: 50 }); // writes to stdout, does not exit
output.writeError("WARN", "retrying"); // writes to stderr, does not exit

Exit codes follow standard conventions:

  • 0 — success
  • non-zero — failure (the caller sees this in the exit event)

Error Handling

When input.parse fails, it throws a StdinParseError with one of three error codes:

CodeCause
EMPTY_STDINNo input received (stdin was empty or whitespace-only)
INVALID_JSONInput is not valid JSON
VALIDATION_ERRORJSON does not match the Zod schema

Pattern 1: Fail on bad input — Use when input is required.

import { z } from "zod";
import { input, output } from "@hmcs/sdk/commands";

try {
const data = await input.parse(
z.object({ linkedVrm: z.number() })
);
// ... use data ...
} catch (e) {
output.fail("INVALID_INPUT", (e as Error).message);
}
tip

For menu commands that only need the linked VRM, use input.parseMenu() instead — it handles the schema and returns a Vrm instance directly.

Pattern 2: Fall back to defaults — Use when input is optional.

import { z } from "zod";
import { input, StdinParseError } from "@hmcs/sdk/commands";

const defaults = { host: "http://localhost:50021" };
let parsed = defaults;
try {
parsed = await input.parse(
z.object({ host: z.string().default(defaults.host) })
);
} catch (err) {
if (!(err instanceof StdinParseError)) throw err;
// Use defaults if stdin is empty or malformed
}

Execution

HTTP API

Bin commands are invoked via the HTTP API:

POST http://localhost:3100/commands/execute
Content-Type: application/json

Request Parameters

FieldTypeRequiredDescription
commandstringYesCommand name to execute (as declared in bin)
argsstring[]NoArguments passed to the script. Max 64 items, each max 4096 characters.
stdinstringNoData written to the process stdin. Stdin is closed after writing. Max 1 MiB.
timeoutMsnumberNoTimeout in milliseconds. Range: 1–300,000. Default: 30,000 (30 seconds).

Response Format

The response is an NDJSON (newline-delimited JSON) stream. Each line is one of three event types:

stdout — A line of standard output from the script:

{"type":"stdout","data":"Hello, world!"}

stderr — A line of standard error output:

{"type":"stderr","data":"Warning: using default config"}

exit — The process has exited (always the last event):

{"type":"exit","code":0,"timedOut":false}

The exit event may also include a signal field (e.g., "15") if the process was killed by a signal rather than exiting normally.

SDK Wrappers

The @hmcs/sdk provides two convenience functions for calling bin commands from other MOD scripts:

  • mods.executeCommand(request) — Buffers all output and returns a single CommandResult with stdout, stderr, exitCode
  • mods.streamCommand(request) — Returns an AsyncGenerator<CommandEvent> for real-time streaming

See the Mods API reference for full details.

Complete Example

Here is a complete bin command that builds a greeting message based on input parameters.

package.json (relevant fields):

{
"name": "my-mod",
"type": "module",
"bin": {
"my-mod:greet": "bin/greet.ts"
},
"dependencies": {
"@hmcs/sdk": "latest",
"zod": "^3.25.0"
}
}

bin/greet.ts:

#!/usr/bin/env tsx

/// <reference types="node" />

import { z } from "zod";
import { input, output, StdinParseError } from "@hmcs/sdk/commands";

// Define input schema with defaults
const schema = z.object({
name: z.string().default("World"),
language: z.enum(["en", "ja"]).default("en"),
});

// Parse stdin, fall back to defaults if empty
const defaults = { name: "World", language: "en" as const };
let parsed: z.infer<typeof schema> = defaults;
try {
parsed = await input.parse(schema);
} catch (err) {
if (!(err instanceof StdinParseError)) throw err;
}

// Build greeting
const greetings = { en: "Hello", ja: "こんにちは" };
const greeting = greetings[parsed.language];
const message = `${greeting}, ${parsed.name}!`;

// Output result and exit
output.succeed({ message });

Invoke with curl:

# With input
curl -X POST http://localhost:3100/commands/execute \
-H "Content-Type: application/json" \
-d '{"command": "my-mod:greet", "stdin": "{\"name\": \"Alice\", \"language\": \"ja\"}"}'

# Without input (uses defaults)
curl -X POST http://localhost:3100/commands/execute \
-H "Content-Type: application/json" \
-d '{"command": "my-mod:greet"}'