decopin-cli

↑ ↓ to navigate · Enter to open · Esc to close

Conventionsdata.tsx and output.tsx

data.tsx and output.tsx

Split computing from displaying, get --json for free, and declare the shape of what you return.

A command can split in two: data.tsx computes, cmd.tsx displays. The return value of data.tsx arrives as the data prop, fully typed, with no annotation needed, because the generated types read it back through TypeScript's own inference.

// app/stats/data.tsx
import type { CmdProps } from 'decopin-cli';

export default function Data({ options }: CmdProps<'stats'>) {
  const files = ['README.md', 'package.json'];
  return { files, total: files.length };
}
// app/stats/cmd.tsx
import { KeyValue, List, type CmdProps } from 'decopin-cli';

export default function Command({ data }: CmdProps<'stats'>) {
  // data.files is string[], data.total is number
  return (
    <>
      <List items={data.files} />
      <KeyValue data={{ total: data.total }} />
    </>
  );
}

Splitting buys you --json, which is reserved by the framework: it skips the view and prints what data.tsx returned.

$ ./dist/index.js stats --json
{
  "files": [
    "README.md",
    "package.json"
  ],
  "total": 2
}

Declaring the shape with output.tsx

output.tsx declares what data.tsx promises. When present it becomes the source of truth: the data prop is typed from the declaration rather than inferred, and the value is checked at runtime before anything is displayed or printed.

// app/stats/output.tsx
import { Output, Type } from 'decopin-cli';

export default function DefineOutput() {
  return (
    <Output>
      <Type.Object>
        <Type.Field name="total" required>
          <Type.Number min={0} integer />
        </Type.Field>
        <Type.Field name="files" required>
          <Type.Array>
            <Type.String minLength={1} />
          </Type.Array>
        </Type.Field>
      </Type.Object>
    </Output>
  );
}

Checking data your own code produced is worth it when it did not really come from your code: return (await res.json()) as User[] type-checks whether or not the response matches. output.tsx is the one place that boundary gets examined. Commands without one keep the inferred type and no check. For awkward shapes, pass a valibot schema instead: <Output schema={v.object(…)} />.

The declaration also becomes the outputSchema of the command's MCP tool.

DataResult: the declaration, checked while you type

With an output.tsx in place, data.tsx can name what it returns. Then a value that disagrees with the declaration fails to type-check, before the runtime check ever runs:

// app/stats/data.tsx
import type { CmdProps, DataResult } from 'decopin-cli';

export default function Data({
  options,
}: CmdProps<'stats'>): DataResult<'stats'> {
  const files = ['README.md', 'package.json'];
  const shown =
    options.limit === undefined ? files : files.slice(0, options.limit);
  // leave out a declared field, or get one wrong, and tsc says so here
  return { counted: shown.length, total: files.length, files: shown };
}

decopin dev writes that annotation for you. It is added only when the command has an output.tsx: without one, the data prop's type is inferred from this function's return value, so annotating it here would make the type refer to itself. Before decopin build has generated types, DataResult<'…'> is unknown, so a fresh checkout still type-checks.

What --json refuses

--json refuses to print data that would not survive the round trip, and names the path rather than letting it break quietly:

$ ./dist/index.js stats --json
data.lookup cannot go into --json: Map becomes {} in JSON

JSON.stringify drops functions and undefined, turns Map and Set into {}, and turns NaN into null, all without complaining. Date and Temporal values become strings that the declared type never mentions. Export plain data, or convert at the edge (when: when.toISOString()). The JsonValue type is exported if you would rather assert it yourself with satisfies.

Failures under --json

When a command fails under --json, the failure is structured too, on stderr, with stdout left empty:

$ ./dist/index.js stats --limit 99 --json
{
  "error": {
    "code": "validation",
    "message": "--limit: Invalid value: Expected <=3 but received 99",
    "exitCode": 2
  }
}

Asking for JSON and getting a human-formatted error back would break the parser on the other end, so error.tsx is skipped here for the same reason the view is. code is what a caller should branch on: messages get reworded, categories do not. exitCode is repeated in the body because a caller reading through a pipe cannot see $? (in cmd | jq, that belongs to jq).

Piping does not switch to JSON

Dropping colour when stdout is not a terminal adjusts presentation; changing the output format would break cli stats | grep README, so it happens only when asked. A command without a data.tsx exits 2 on --json and tells you where to put the file.