Node Library
The Node package (runspec-node) brings the same interface specification to
Node.js and TypeScript projects. Same TOML format, same CLI, same MCP server
— just parse() returning a ParsedArgs object instead of a Python RunSpec.
Version
This page tracks the latest published runspec-node release; API additions are annotated with the version that introduced them. Node 18+ is required; CI covers 18, 20, and 22.
Python parity (node-0.18.0)
As of node-0.18.0, the Node pack is at feature parity with the Python
reference for argument handling — including string validation
(pattern / min-length / max-length) and required subcommands
(require-command). The same runspec.toml produces identical parsing,
validation, and tool schemas in both languages. Any remaining
language-specific differences are called out inline below.
Installation
npm install runspec-node
One runtime dependency: smol-toml (TOML parsing — Node has no stdlib TOML
parser). TypeScript types are bundled — no separate @types/ package. The
runspec binary is installed alongside the library — see
CLI Reference.
parse()
import { parse } from 'runspec-node';
const args = parse();
That's the whole call. runspec finds your config, resolves the runnable name,
parses process.argv, validates, coerces, and returns a ParsedArgs.
Signature
function parse(opts?: ParseOptions): ParsedArgs
interface ParseOptions {
scriptName?: string; // override runnable name (inferred from script filename otherwise)
argv?: string[]; // override process.argv.slice(2)
cwd?: string; // start directory for config search (default: process.cwd())
configPath?: string; // explicit path to runspec.toml (overrides cwd walk)
}
What it does
- Resolves the config file (
configPath→RUNSPEC_CONFIGenv → walk up fromcwd). - Infers the runnable name from the script filename.
- Applies inference rules to fill in
typeandrequired. - Resolves any subcommand from
argv. - Intercepts
--help/-hand prints usage, then exits. - If
[config.logging]is present, configures the logger and injects--debug(see Logging). - Parses
argvinto raw values. - Applies environment variable fallbacks.
- Applies spec defaults.
- Validates individual args, then group constraints.
- Coerces values to native JavaScript types.
- Returns a
ParsedArgs.
Errors
| Exception | When |
|---|---|
RunSpecError |
No config found, runnable not in spec, reserved name used |
MissingRequiredArg |
A required arg was not provided |
InvalidChoice |
Value not in declared options |
OutOfRange |
Numeric value outside declared range |
UnknownArg |
An arg was passed that isn't in the spec |
GroupViolation |
A group constraint was violated |
AutonomyViolation |
Per-arg autonomy escalation was attempted unsafely |
All errors inherit from RunSpecError. Error messages include what was
expected, what was received, and a fuzzy suggestion where possible. For
project-wide validation use runspec local, which surfaces the same errors
at spec-load time (handy in CI).
Testing
Pass argv directly to test without touching process.argv:
import { parse } from 'runspec-node';
test('greet with --loud', () => {
const args = parse({ argv: ['--name', 'Alice', '--loud'] });
expect(args.name).toBe('Alice');
expect(args.loud).toBe(true);
});
ParsedArgs
parse() returns a ParsedArgs — a plain object with coerced argument
values. Hyphens in arg names become underscores; access values by key:
const args = parse();
const name = args.name as string;
const workers = args.workers as number;
const inputDir = args.input_dir as string; // --input-dir → input_dir
const format = args.format as string;
const tags = (args.tag as string[]) ?? [];
const dryRun = args.dry_run as boolean;
In Python (since 0.38.0) each argument is an Arg you read via args.name.value;
Node has no wrapper — args.name IS the coerced value. So the ergonomics the
Python Arg/get() API provides are already native here: a missing arg (e.g. one
that belongs to a different subcommand) is undefined, 'name' in args tests
membership, and args.name ?? fallback supplies a default. Cast to the TypeScript
type you expect, or use a small helper:
function get<T>(args: ParsedArgs, key: string): T {
return args[key] as T;
}
const workers = get<number>(args, 'workers');
Effective value type — tsTypeOf()
A multiple arg's value is an array, and per-item validation applies the
arg's type to each element — so type stays the item type. At runtime
args.tag is already the array, so Array.isArray(args.tag) answers the
question directly. For tooling, codegen, or debugging, tsTypeOf computes the
parsed value's TypeScript type from a (typically inferred) ArgSpec:
import { tsTypeOf } from 'runspec-node';
const spec = args.__runspec_spec__;
spec.args.tag.type // 'str' — item type (unchanged)
tsTypeOf(spec.args.tag) // 'string[]' — multiple → array
tsTypeOf(spec.args.workers) // 'number'
args.tag // ['a', 'b'] — the value is already an array
It's a pure helper (not a field on the spec); custom registered types fall back
to 'unknown'. The canonical machine-readable shape is the emitted JSON Schema
(runspec local --format json), where a multiple arg is
{"type":"array","items":{…}}. (Per-argument editor types via generated .d.ts
are a possible future follow-up — see the
stubs design.)
Metadata properties
ParsedArgs exposes invocation context. The __runspec_*__ keys are the
storage; the runspec_* properties below them are the recommended API:
| Property | Type | Description |
|---|---|---|
__runspec_script__ |
string |
Name of the runnable |
__runspec_source__ |
string |
Absolute path to runspec.toml |
__runspec_command_path__ |
string[] |
Subcommand path, deepest last |
__runspec_autonomy__ |
string |
Effective autonomy after escalation |
__runspec_agent__ |
boolean |
true under runspec serve (RUNSPEC_AGENT=1) |
__runspec_spec__ |
ScriptSpec |
Raw, fully-inferred spec for the runnable |
runspec_command |
string \| undefined |
Active subcommand (leaf) |
runspec_command_path |
string[] |
Same as __runspec_command_path__ |
runspec_prefix |
string |
Package root: directory containing runspec.toml |
const args = parse();
console.log(args.__runspec_script__); // "deploy"
console.log(args.runspec_command); // "run" (if a subcommand was matched)
console.log(args.__runspec_autonomy__); // "confirm"
console.log(args.__runspec_agent__); // true under runspec serve
console.log(args.runspec_prefix); // "/home/user/project/mypkg"
runspec_prefix — package-relative paths
import * as path from 'path';
import * as fs from 'fs';
const args = parse();
const templatesDir = path.join(args.runspec_prefix, 'templates');
const files = fs.readdirSync(templatesDir);
Much sturdier than __dirname, which moves around when the runnable is
invoked via a wrapper or runspec serve.
Autonomy gating
__runspec_autonomy__ reflects the most restrictive level across the
runnable, its args, and any per-arg overrides. Use it to refuse agent
invocation of destructive actions:
if (args.delete && args.__runspec_agent__ && args.__runspec_autonomy__ !== 'autonomous') {
console.error("✗ --delete requires autonomy='autonomous' for agent invocation");
process.exit(1);
}
Agent-aware output
if (args.__runspec_agent__) {
console.log(JSON.stringify({ status: 'deployed', env: args.env }));
} else {
console.log(`✓ Deployed to ${args.env}`);
}
loadSpec()
import { loadSpec } from 'runspec-node';
const spec = loadSpec();
const specForDeploy = loadSpec({ scriptName: 'deploy', cwd: '/path/to/project' });
Loads the spec without parsing process.argv. Returns a ParsedArgs with
default values only — no CLI args applied. Accepts the same ParseOptions
as parse(). Used for tooling, introspection, and code generation:
import { loadSpec } from 'runspec-node';
import type { ScriptSpec } from 'runspec-node';
const spec = loadSpec({ scriptName: 'deploy' });
const scriptSpec = spec.__runspec_spec__ as ScriptSpec;
for (const [name, arg] of Object.entries(scriptSpec.args)) {
console.log(`${name}: ${arg.type} (required=${arg.required})`);
}
registerType()
import { registerType, listTypes } from 'runspec-node';
function registerType(name: string, coercer: (value: unknown) => unknown): void
function listTypes(): string[]
Register a custom type. The coercer receives the raw value and returns the coerced value. Throw to produce a clean error message.
import * as fs from 'fs';
import { registerType } from 'runspec-node';
registerType('json-file', (v) =>
JSON.parse(fs.readFileSync(v as string, 'utf-8'))
);
registerType('port', (v) => {
const port = Number(v);
if (!Number.isInteger(port) || port < 1 || port > 65535)
throw new Error(`${v} is not a valid port number`);
return port;
});
Then in your spec:
[server.args]
config = {type = "json-file"}
port = {type = "port", default = 8080}
Logging integration (getLogger)
When [config.logging] is present in your runspec.toml, parse()
configures a lightweight logger automatically. Call getLogger(name) to
obtain a named logger — that's the entire integration.
import { parse, getLogger } from 'runspec-node';
const logger = getLogger('deploy');
function main(): void {
const args = parse();
logger.info('Deploy starting for %s', args.target);
logger.info('Result', {
target: args.target,
duration_ms: 1240,
});
}
main();
The trailing object becomes structured extra fields; the special error
key extracts an Error:
try {
await runDeploy(args);
} catch (err) {
logger.error('Deploy failed', { target: args.target, error: err });
process.exit(1);
}
Sensitive-data redaction (passwords, tokens, Authorization headers, URL
credentials) is applied to every log line. See Logging for
the full picture: rotation policies, agent-mode behaviour, and the
auto-injected --debug flag.
Exports
Everything runspec-node exposes from the package root:
| Export | Kind | Description |
|---|---|---|
parse |
function | Parse argv, return ParsedArgs |
loadSpec |
function | Load spec without parsing argv |
registerType |
function | Register a custom type coercer |
listTypes |
function | List all registered type names |
tsTypeOf |
function | The TS type of an arg's parsed value (e.g. "string[]") |
getLogger |
function | Get a named logger (no-op without [config.logging]) |
findConfig |
function | Locate the nearest runspec.toml |
loadRaw |
function | Read and parse a runspec.toml to its raw dict form |
RunSpecError |
class | Base error class |
MissingRequiredArg, InvalidChoice, OutOfRange, UnknownArg, GroupViolation, AutonomyViolation |
classes | Specific error subclasses |
ParsedArgs, ScriptSpec, ArgSpec, GroupSpec, RawSpec, RawConfig, LoggingConfig |
types | TypeScript interfaces |
Errors
import {
RunSpecError, // base class
MissingRequiredArg,
InvalidChoice,
OutOfRange,
UnknownArg,
GroupViolation,
AutonomyViolation,
} from 'runspec-node';
Error messages include context, expected values, and fuzzy suggestions:
✗ Missing required argument: --input
Type: path
Tip: set environment variable PIPELINE_INPUT as an alternative
✗ Invalid value for --format: 'yml'
Expected one of: json, csv, parquet
Got: 'yml'
Did you mean: json?
Catch the base class for uniform handling:
try {
const args = parse();
// ... your runnable ...
} catch (e) {
if (e instanceof RunSpecError) {
console.error(e.message);
process.exit(1);
}
throw e;
}
CLI
The runspec binary is included in runspec-node. Use it via npx in any
project that has runspec-node installed:
npx runspec init # scaffold runspec.toml + code stub
npx runspec local # list installed runnables and validate
npx runspec local --format mcp # emit MCP tool schemas
npx runspec serve # start the MCP stdio server
Or install globally for direct access:
npm install -g runspec-node
runspec local
The CLI is identical to the Python version and reads the same runspec.toml
format. If both runspec (Python) and runspec-node are installed, either
binary works.
Jump-host execution
runspec jump is fully implemented in the Python CLI. The Node CLI
accepts the jump subcommand but currently prints a pointer to the
Python package; full Node parity is on the roadmap. See
Jump Hosts.
See the CLI Reference for full documentation of all commands.
MCP server
runspec serve starts a JSON-RPC 2.0 MCP stdio server for your project. It
reads your runspec.toml, exposes all runnables as tool schemas, and
executes them when an agent calls a tool.
How it finds scripts: the Node serve command looks in node_modules/.bin/
relative to your config file. Any package you install that declares a bin
entry appears there automatically. Name your runnable the same as the binary
it wraps.
project/
runspec.toml # [process] runnable defined here
node_modules/
.bin/
process # ← runspec serve finds this and runs it
Connect Claude Desktop or any MCP client:
{
"mcpServers": {
"my-tools": {
"command": "npx",
"args": ["--yes", "runspec-node", "serve"],
"cwd": "/path/to/project"
}
}
}
{
"mcpServers": {
"my-tools": {
"command": "runspec",
"args": ["serve"],
"cwd": "/path/to/project"
}
}
}
{
"mcpServers": {
"my-tools": {
"command": "npm",
"args": ["run", "serve"],
"cwd": "/path/to/project"
}
}
}
With "serve": "runspec serve" in your package.json scripts.
See Agent Integration for autonomy gating, agent-aware output,
and the RUNSPEC_AGENT convention.
Complete example
# runspec.toml
[config.logging]
rotate = "midnight"
keep = 7
[process]
description = "Process input files"
autonomy = "confirm"
[process.args]
input = {type = "path"}
format = {options = ["json", "csv"], default = "json"}
workers = {default = 4, range = [1, 16]}
dry-run = {default = false}
verbose = {default = false, short = "-v"}
api-key = {type = "str", env = "PROCESS_API_KEY", autonomy = "manual"}
tag = {type = "str", multiple = true}
import { parse, getLogger } from 'runspec-node';
const logger = getLogger('process');
function main(): void {
const args = parse();
const input = args.input as string;
const format = args.format as string;
const workers = args.workers as number;
const dryRun = args.dry_run as boolean;
const verbose = args.verbose as boolean;
const tags = (args.tag as string[]) ?? [];
const isAgent = args.__runspec_agent__;
logger.info('Run starting', { format, workers, tags });
if (dryRun) {
if (isAgent) {
console.log(JSON.stringify({ status: 'dry-run', input }));
} else {
console.log(`[dry run] would process ${input} as ${format}`);
}
return;
}
// ... do the work ...
if (isAgent) {
console.log(JSON.stringify({ status: 'ok', tags }));
} else if (verbose) {
console.log(`Processed ${input} with ${workers} workers`);
if (tags.length) console.log(`Tags: ${tags.join(', ')}`);
}
}
main();
Running it:
node dist/process.js --input data.csv --workers 8 --tag etl --tag prod
Or from an agent via runspec serve — no code change needed. __runspec_agent__
switches the output format automatically.