# Build a command-line tool

> Write a command-line tool in TypeScript with Elide and no dependencies: argument parsing, files and stdin, color, prompts, exit codes and a sandbox.

Elide runs a TypeScript file directly, so a command-line tool is one executable `.ts` file with no `package.json`,
build step or dependencies. This page builds `logscan`, which counts HTTP status codes in access logs, and then adds
a confirmation prompt, a sandbox and a time limit.

## The tool

```ts
#!/usr/bin/env elide
// logscan.ts
import { createReadStream, existsSync } from "node:fs";
import { createInterface } from "node:readline";
import { parseArgs, styleText } from "node:util";

const usage = `Usage: logscan [--json] [--min STATUS] [FILE...]

Counts HTTP status codes in access logs. Reads stdin when no FILE is given.`;

let args;
try {
  args = parseArgs({
    allowPositionals: true,
    options: {
      json: { type: "boolean" },
      min: { type: "string", default: "100" },
      help: { type: "boolean", short: "h" },
    },
  });
} catch (err) {
  console.error(`logscan: ${(err as Error).message}\n\n${usage}`);
  process.exit(2);
}
const { values, positionals } = args;
if (values.help) {
  console.log(usage);
  process.exit(0);
}
for (const file of positionals) {
  if (!existsSync(file)) {
    console.error(`logscan: no such file: ${file}`);
    process.exit(2);
  }
}

const min = Number(values.min);
const counts = new Map<number, number>();
const inputs = positionals.length
  ? positionals.map((file) => createReadStream(file))
  : [process.stdin];
for (const input of inputs) {
  for await (const line of createInterface({ input, crlfDelay: Infinity })) {
    const status = Number(line.trim().split(/\s+/).at(-1));
    if (status >= min) counts.set(status, (counts.get(status) ?? 0) + 1);
  }
}

const sorted = [...counts].sort(([a], [b]) => a - b);
if (values.json) {
  console.log(JSON.stringify(Object.fromEntries(sorted)));
} else {
  const color = process.stdout.isTTY && !process.env.NO_COLOR;
  for (const [status, count] of sorted) {
    const tint = status >= 500 ? "red" : status >= 400 ? "yellow" : "green";
    console.log(`${color ? styleText(tint, String(status)) : status}  ${count}`);
  }
}
process.exitCode = sorted.some(([status]) => status >= 500) ? 1 : 0;
```

Make it executable and give it a log:

```bash
chmod +x logscan.ts
printf 'GET /a 200\nGET /b 404\nPOST /a 200\nGET /c 500\nGET /d 404\n' > access.log
./logscan.ts access.log
```

```text
200  2
404  2
500  1
```

The exit status is `1` because the log has a `500`. Scripts and CI steps can branch on it.

## Arguments

`parseArgs` from [`node:util`](https://elide.help/docs/api-node-util) reads `process.argv` and returns typed `values` and `positionals`.
An unknown option throws an error with code `ERR_PARSE_ARGS_UNKNOWN_OPTION`; the tool prints it with the usage and
exits `2`, the usual status for bad usage:

```bash
./logscan.ts --bogus
```

```text
logscan: Unknown option '--bogus'

Usage: logscan [--json] [--min STATUS] [FILE...]

Counts HTTP status codes in access logs. Reads stdin when no FILE is given.
```

Machine-readable output keeps the tool composable:

```bash
./logscan.ts --json --min 400 access.log
```

```text
{"404":2,"500":1}
```

## Input

With no file arguments the tool reads stdin, so it works in a pipeline:

```bash
grep -v ' 500$' access.log | ./logscan.ts --min 400
```

```text
404  2
```

`createInterface` from [`node:readline`](https://elide.help/docs/api-node-readline) turns any readable stream into lines. To read all of
stdin at once, concatenate `for await (const chunk of process.stdin)` in a module.

## Output and color

Results go to stdout and errors to stderr, so `./logscan.ts access.log > counts.txt` still shows errors. Color only
when stdout is a terminal and `NO_COLOR` is unset: `logscan` checks both before calling `styleText`, so redirected
output and logs stay plain.

## Ask before acting

Ask before changing anything, and never block a script waiting for an answer. This helper asks only
on a terminal; anywhere else it answers no, and `--yes` skips the question:

```ts
// clean.ts
import { readdirSync, rmSync } from "node:fs";
import { createInterface } from "node:readline/promises";
import { parseArgs } from "node:util";

async function confirm(question: string): Promise<boolean> {
  if (!process.stdin.isTTY) return false;
  const rl = createInterface({ input: process.stdin, output: process.stdout });
  try {
    return /^y(es)?$/i.test((await rl.question(`${question} [y/N] `)).trim());
  } finally {
    rl.close();
  }
}

const { values } = parseArgs({ options: { yes: { type: "boolean", short: "y" } } });
const logs = readdirSync(".").filter((name) => name.endsWith(".log"));
if (!values.yes && !(await confirm(`Delete ${logs.length} log files?`))) {
  console.error("Nothing deleted; pass --yes to skip the question.");
  process.exit(1);
}
for (const name of logs) rmSync(name);
console.log(`Deleted ${logs.length} files.`);
```

In a terminal with two `.log` files:

```text
Delete 2 log files? [y/N] y
Deleted 2 files.
```

## Elide flags in the shebang

`env -S` passes flags to Elide from the first line. Limit what the tool can read with
[sandbox flags](https://elide.help/docs/sandbox), so a bug or a hostile log cannot reach the rest of the machine:

```ts
#!/usr/bin/env -S elide --sandbox --allow-read=.
```

With that line, `logscan` reads files under the current directory, and `./logscan.ts /etc/hostname` reports
`logscan: no such file: /etc/hostname` because the read is denied.

Other flags that suit tools:

| Flag | Effect |
| --- | --- |
| `--no-env` | Skip `.env` files in the working directory; see [Environment and properties](https://elide.help/docs/environment). |
| `--timeout 30s` | Stop the run after 30 seconds with exit status `124`. |
| `--max-mem 256m` | Cap the heap. |

Exit statuses Elide itself uses are listed in [Exit codes](https://elide.help/docs/cli-references#exit-codes).

## Install it

Elide picks the language from the file name, so the tool keeps its `.ts` extension. To run it as plain `logscan`, put
a two-line wrapper on your `PATH`; it runs the file through its shebang, flags included:

```bash
printf '#!/bin/sh\nexec "%s/logscan.ts" "$@"\n' "$PWD" > ~/.local/bin/logscan
chmod +x ~/.local/bin/logscan
logscan --help
```

Python tools work the same way with `#!/usr/bin/env elide` and `argparse`; see [Python](https://elide.help/docs/python).
