Add a custom CLI command
Ship a custom command alongside generated commands and preserve it through a Definition update.
Add parcel summary to your generated CLI to print shipment IDs and their count. This guide shows how to reuse saved credentials and preserve the command through regeneration.
The wrapper calls the generated CLI as a subprocess, so it reuses credential profiles, refresh, argument validation, and API error handling. It adds its own help and discovery metadata. Typeship does not provide a custom-command registration API: you own this wrapper and its behavior.
Use this approach when customers should log in once and run generated and custom commands together. For typed client calls with explicit deadlines, cancellation, and resume state, see Add a custom workflow. That recipe supplies credentials through environment variables; it does not reuse saved CLI profiles. A wrapper can also own multi-step state while invoking generated commands.
Prepare the CLI package
You need Node.js 20 or later, npm, and a CLI Target with a linked repository Delivery. Use the fictional Parcel API Definition from Customize generated packages. Its /shipments response is an unpaginated array. The domain api.parcel.example is illustrative; the test below supplies a local API.
Set the CLI Target's package name to parcel-client and cli.command_name to parcel, preserving other configuration. Generate, check out the Target's rolling Draft, and enter its package directory. Keep the generated src/cli.ts and add this file:
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { parseArgs } from "node:util";
const generated = fileURLToPath(new URL("./cli.js", import.meta.url));
const options = {
help: { type: "boolean", short: "h" },
version: { type: "boolean", short: "v" },
profile: { type: "string" },
"base-url": { type: "string" },
token: { type: "string" },
credentials: { type: "string" },
mode: { type: "string" },
format: { type: "string" },
json: { type: "boolean" },
"non-interactive": { type: "boolean" },
debug: { type: "boolean" },
validate: { type: "boolean" },
color: { type: "string" },
} as const;
const allowedValues = { mode: ["agent", "human"], format: ["json"], color: ["on", "off", "auto"] };
const summary = {
command: "summary",
description: "Read all shipments and return their count and IDs.",
usage: "parcel summary [options]",
options,
allowed_values: allowedValues,
output: "json",
read_only: true,
};
class UsageError extends Error {}
function parseSummary(args: string[]) {
try {
const { values } = parseArgs({ args, options, strict: true, allowPositionals: false });
for (const [key, choices] of Object.entries(allowedValues)) {
const value = values[key as keyof typeof allowedValues];
if (value !== undefined && !choices.includes(value)) {
throw new Error("--" + key + " must be " + choices.join(" or ") + ".");
}
}
return values;
} catch (error) {
throw new UsageError(error instanceof Error ? error.message : String(error));
}
}
function invoke(args: string[], capture = false): string {
const result = spawnSync(process.execPath, [generated, ...args], {
stdio: capture ? ["inherit", "pipe", "inherit"] : "inherit",
encoding: "utf8",
maxBuffer: 8 * 1024 * 1024,
});
if (result.error) throw result.error;
if (result.signal) {
process.kill(process.pid, result.signal);
process.exit(1);
}
if (result.status !== 0) {
if (capture && result.stdout) process.stdout.write(result.stdout);
process.exit(result.status ?? 1);
}
return result.stdout ?? "";
}
try {
const args = process.argv.slice(2);
const jsonHelp = args.includes("--json") || args.includes("--format=json") ||
args.some((arg, index) => arg === "--format" && args[index + 1] === "json");
if (args[0] === "summary") {
const values = parseSummary(args.slice(1));
if (values.help) {
console.log(JSON.stringify(summary));
} else if (values.version) {
invoke(["--version"]);
} else {
const output = invoke(["shipments", "list", ...args.slice(1), "--json"], true);
const shipments: unknown = JSON.parse(output);
if (!Array.isArray(shipments) || !shipments.every((item) => typeof item?.shipment_id === "string")) {
throw new Error("Expected an array of shipments with shipment_id strings.");
}
console.log(JSON.stringify({
shipment_count: shipments.length,
shipment_ids: shipments.map((item) => item.shipment_id),
}));
}
} else if (
(args[0] === "help" && jsonHelp) ||
(args[0] === "agent-guide" && !args.some((arg) => ["--help", "-h"].includes(arg)))
) {
const guide = JSON.parse(invoke(args, true));
console.log(JSON.stringify({ ...guide, custom_commands: [summary] }));
} else if (args.length === 1 && ["--help", "-h", "help"].includes(args[0]!)) {
process.stdout.write(invoke(args, true) + "\nCustom command: " + summary.usage + "\n");
} else {
invoke(args);
}
} catch (error) {
const usage = error instanceof UsageError;
console.error(JSON.stringify({
status: "error",
issues: [{
code: usage ? "INVALID_USAGE" : "CALL_FAILED",
message: error instanceof Error ? error.message : String(error),
}],
next_steps: usage
? ["Run 'parcel summary --help' and use its listed options."]
: ["Run 'parcel shipments list --json' with the same profile and base URL to inspect the response.",
"Check the wrapper build and shipment_id values before retrying."],
}));
process.exitCode = usage ? 2 : 1;
}Point the package's existing parcel executable at the wrapper, then build:
npm pkg set 'bin.parcel=dist/parcel.js'
npm install
npm run build
node dist/parcel.js --help
node dist/parcel.js help --jsonThe generated build includes src/parcel.ts in dist, which is already published by the package. Root help lists summary. summary --help, help --json, and agent-guide share the command's supported options and allowed values. Generated commands retain their existing discovery data.
Put options after summary, for example parcel summary --mode agent --profile work. The command supports profile and credential selection, agent mode, debugging, and validation. Summary results and wrapper errors are JSON. Wrapper errors use issues[].code and next_steps, with exit code 2 for invalid usage and 1 for command failures. Generated API errors and exit codes pass through unchanged.
This example buffers one unpaginated response, up to 8 MiB. For paginated endpoints, fetch every page before calculating the summary. Response filtering, file output, and pagination options (--fields, --out, and --all) are not supported by summary. Add your own descriptions, validation, and safety behavior when extending it. Shell completion, generated API reference, and the command index written by init do not automatically include wrapper commands.
Verify the command and shared authentication
Save this test. It starts a local API, logs in with a fixture token in an isolated profile, and runs both custom and generated commands:
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
import { promisify } from "node:util";
test("custom commands share authentication and the generated agent contract", async () => {
const config = await mkdtemp(join(tmpdir(), "parcel-cli-"));
const shipments = [{ shipment_id: "shp_123" }, { shipment_id: "shp_456" }];
const requests = [];
let status = 200;
let body = shipments;
const server = createServer((req, res) => {
requests.push({ path: req.url, token: req.headers.authorization });
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(status === 200 ? body : { message: "Access denied" }));
});
const exec = promisify(execFile);
const runCli = (entry, ...args) => exec(process.execPath, [entry, ...args], {
timeout: 20_000,
env: {
...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("PARCEL_"))),
XDG_CONFIG_HOME: config,
PARCEL_CREDENTIAL_STORE: "file",
PARCEL_PROFILE: "docs-test",
},
});
const run = (...args) => runCli("dist/parcel.js", ...args);
const failure = async (...args) => {
try { await run(...args); assert.fail("Expected failure"); }
catch (error) { assert.equal(typeof error.code, "number"); return error; }
};
try {
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const base = `http://127.0.0.1:${server.address().port}`;
await run("login", "--token", "fixture-token", "--base-url", base);
const summary = JSON.parse((await run("summary", "--base-url", base)).stdout);
assert.deepEqual(summary, { shipment_count: 2, shipment_ids: ["shp_123", "shp_456"] });
const agent = await run("summary", "--mode", "agent", "--profile", "docs-test");
assert.deepEqual(JSON.parse(agent.stdout), summary);
const listed = await run("shipments", "list", "--base-url", base, "--json");
assert.deepEqual(JSON.parse(listed.stdout), shipments);
assert(requests.length >= 2);
assert(requests.every((req) => req.path === "/shipments" && req.token === "Bearer fixture-token"));
const explicit = await run("summary", "--mode", "agent", "--profile", "docs-test",
"--base-url", base, "--token", "override-token", "--format", "json", "--json",
"--non-interactive", "--debug", "--validate", "--color", "off");
assert.deepEqual(JSON.parse(explicit.stdout), summary);
assert.equal(requests.at(-1).token, "Bearer override-token");
assert.match(explicit.stderr, /GET/);
const credentials = join(config, "input.json");
await writeFile(credentials, JSON.stringify({ bearerAuth: "named-token" }));
assert.deepEqual(JSON.parse((await run("summary", "--credentials", "@" + credentials)).stdout), summary);
assert.equal(requests.at(-1).token, "Bearer named-token");
const count = requests.length;
const help = JSON.parse((await run("summary", "--help", "--mode", "agent")).stdout);
assert.deepEqual(Object.keys(help.options).sort(), ["help", "version", "profile", "base-url",
"token", "credentials", "mode", "format", "json", "non-interactive", "debug", "validate", "color"].sort());
assert.deepEqual(help.allowed_values, { mode: ["agent", "human"], format: ["json"], color: ["on", "off", "auto"] });
assert.equal(help.options.mode.type, "string");
assert.equal(help.output, "json");
assert.equal(help.read_only, true);
assert.deepEqual(JSON.parse((await run("summary", "-h")).stdout), help);
assert.equal((await run("summary", "--version")).stdout, (await run("--version")).stdout);
assert.equal((await run("summary", "-v")).stdout, (await run("--version")).stdout);
assert.match((await run("--help")).stdout, /parcel summary/);
assert.match((await run("-h")).stdout, /parcel summary/);
assert.equal((await run("agent-guide", "-h")).stdout, (await run("agent-guide", "--help")).stdout);
for (const args of [["help", "--json"], ["help", "--format", "json"], ["help", "--format=json"],
["agent-guide", "--format", "json"]]) {
const guide = JSON.parse((await run(...args)).stdout);
assert.deepEqual(guide.custom_commands, [help]);
}
for (const flags of [["--unknown"], ["--fields", "shipment_id"], ["--out", "files"], ["--all"],
["--mode", "robot"], ["--format", "yaml"], ["--color", "purple"], ["--profile"], ["extra"]]) {
const error = await failure("summary", "--mode", "agent", ...flags);
assert.equal(error.code, 2);
assert.equal(error.stdout, "");
const envelope = JSON.parse(error.stderr);
assert.equal(envelope.status, "error");
assert.equal(envelope.issues[0].code, "INVALID_USAGE");
assert(envelope.issues[0].message.length > 0);
assert.match(envelope.next_steps[0], /parcel summary --help/);
}
assert.equal(requests.length, count);
body = [{ id: "missing-shipment-id" }];
const malformed = await failure("summary", "--mode", "agent");
assert.equal(malformed.code, 1);
assert.equal(malformed.stdout, "");
const envelope = JSON.parse(malformed.stderr);
assert.equal(envelope.status, "error");
assert.equal(envelope.issues[0].code, "CALL_FAILED");
assert.match(envelope.issues[0].message, /shipment_id/);
assert.match(envelope.next_steps[0], /parcel shipments list --json/);
status = 401;
const custom = await failure("summary", "--mode", "agent", "--base-url", base);
const generated = await runCli("dist/cli.js", "shipments", "list", "--mode", "agent", "--base-url", base, "--json")
.then(() => assert.fail("Expected API failure"), (error) => error);
assert.equal(custom.code, 1);
assert.equal(custom.code, generated.code);
assert.equal(custom.stdout, "");
assert.equal(generated.stdout, "");
assert.equal(JSON.parse(custom.stderr).issues[0].code, "AUTH_INVALID");
assert.equal(custom.stderr, generated.stderr);
} finally {
await new Promise((resolve) => server.close(resolve));
await rm(config, { recursive: true, force: true });
}
});The fixture opts into a plaintext credential file only inside its temporary directory. Normal customer logins keep the generated CLI's default OS-backed storage.
npm pkg set 'scripts.test:custom-cli=node --test tests/parcel-cli.test.mjs'
npm run build
npm run test:custom-cli
npm pack --dry-runExpect one passing test. Confirm the package includes both dist/parcel.js and dist/cli.js. Commit the wrapper, test, and package.json changes to the rolling Draft. Add npm run test:custom-cli to the Target's customer checks, retaining existing checks.
Carry the command through regeneration
Add description: A shipment identifier. to the Definition's shipment_id property, commit the Definition, and generate again. Pull the updated Draft, then repeat the build and custom test. The new description, wrapper executable, and package configuration should all remain present.
Linked repository Deliveries preserve non-overlapping edits. Changes to the same lines, including competing edits to package.json, can require conflict resolution. Review the combined package and its checks before releasing. Stateless generation into a local directory does not preserve edits for you.
Extend onboarding or login
First configure the existing browser login, identity checks, and agent setup. This wrapper passes those commands through unchanged.
Product-specific account provisioning, claim flows, and multi-step workflows still need your implementation. A wrapper can orchestrate existing commands; changing a built-in's internals requires a reviewed source edit and checks. Sharing a generated package does not automatically make a custom authentication flow compatible with its credential store.