Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ on:
branches:
- main
pull_request:
branches:
- main

jobs:
lint:
Expand All @@ -23,11 +21,16 @@ jobs:
- run: pnpm typecheck

test:
name: test (${{ matrix.os }}, node ${{ matrix.node }})
runs-on: ${{ matrix.os }}
strategy:
matrix:
node: [lts/*]
node: [24]
os: [ubuntu-latest, windows-latest, macos-latest]
# The oldest supported Node behaves the same on every OS; one job is enough to hold the engines floor.
include:
- node: 22
os: ubuntu-latest
fail-fast: false
steps:
- uses: actions/checkout@v4
Expand All @@ -40,3 +43,4 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: pnpm test
- run: pnpm examples
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- `clikit/json`: `lastJson(text, validate?)`, `tryLastJson(text, validate?)` and `lastJsonObject(text, validate?)` read the JSON value that ends CLI output or the last object in model output, through ANSI escapes and CRLF, with an optional type guard.
- `clikit/proc`: `spawnGroup`, `killGroup` (SIGTERM, grace period, SIGKILL to the group and every detached group beneath it), `isAlive`, `cwdOf` and `startOf`.
- `clikit/proc`: `spawnGroup`, `killGroup` (SIGTERM to the group and every detached group beneath it, a grace period, then SIGKILL), `isAlive`, `cwdOf` and `startOf`.
- `clikit/watchdog`: `Watchdog` with a wall-clock and a stall limit.
- `clikit/lock`: `acquireLock` pidfile lock with stale-pid detection, and `LockHeld`.
- `clikit/env`: `withoutSecrets`, `withoutGitVars` and `SECRET_VARS`.
Expand Down
57 changes: 40 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,29 @@
[![npm downloads](https://img.shields.io/npm/dm/clikit)](https://www.npmjs.com/package/clikit)
[![CI](https://github.com/devmaxxx/clikit/actions/workflows/ci.yml/badge.svg)](https://github.com/devmaxxx/clikit/actions/workflows/ci.yml)
[![types](https://img.shields.io/npm/types/clikit)](https://www.npmjs.com/package/clikit)
[![node](https://img.shields.io/node/v/clikit)](./package.json)
[![zero dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)](./package.json)
[![bundle size](https://img.shields.io/bundlejs/size/clikit)](https://bundlejs.com/?q=clikit)
[![license: MIT](https://img.shields.io/npm/l/clikit)](./LICENSE)

[Install](#install) · [Quick start](#quick-start) · [API](#api) · [Recipes](#recipes) · [Alternatives](#compared-with-single-purpose-packages) · [Changelog](./CHANGELOG.md)

</div>

## Contents

- [Why](#why)
- [Features](#features)
- [Install](#install)
- [Quick start](#quick-start)
- [API](#api): [json](#clikitjson) · [proc](#clikitproc) · [watchdog](#clikitwatchdog) · [lock](#clikitlock) · [env](#clikitenv) · [log](#clikitlog) · [errors](#clikiterrors)
- [Recipes](#recipes)
- [Compared with single-purpose packages](#compared-with-single-purpose-packages)
- [Limits](#limits)
- [Stability](#stability)
- [Contributing](#contributing)
- [License](#license)

## Why

A tool that drives other tools — a task runner, a CI bot, an AI agent harness — keeps hitting the same handful of problems:
Expand Down Expand Up @@ -54,7 +71,7 @@ yarn add clikit
bun add clikit
```

Requires Node.js 20 or later.
Requires Node.js 22 or later.

> **Pick only what you need.** `import { lastJson } from 'clikit/json'` loads just that module. The root `clikit` re-exports everything, and bundlers tree-shake it too.

Expand Down Expand Up @@ -113,11 +130,11 @@ tryLastJson('no json here') // undefined

#### `lastJsonObject(text, validate?)`

The last `{…}` object in `text` — a model's answer in prose or a code fence — or `undefined`. It ends at the last `}` and walks back over `{` until the slice parses. Arrays and scalars are not looked for, because prose is full of `[1]`-style brackets.
The last `{…}` object in `text` — a model's answer in prose or a code fence — or `undefined`. It walks back from the last `}` over `{` until a slice parses, and from an earlier `}` when a stray one in later prose ends nothing. Arrays and scalars are not looked for, because prose is full of `[1]`-style brackets.

#### `validate`: plug in zod, valibot or your own guard

All three accept an optional type guard `(value: unknown) => value is T` and then return `T`. A value the guard rejects is skipped: `lastJson` tries an earlier line start, `lastJsonObject` an enclosing brace. No schema library is a dependency.
All three accept an optional type guard `(value: unknown) => value is T` and then return `T`. A value the guard rejects is skipped: `lastJson` tries an earlier line start, `lastJsonObject` an enclosing brace and then an earlier object. No schema library is a dependency.

```ts
import { z } from 'zod'
Expand All @@ -130,15 +147,15 @@ const pr = lastJson(stdout, isPr) // typed as { number: number, title: string }

### `clikit/proc`

Process groups and process facts. **POSIX only for groups:** `killGroup` throws on Windows, which has no process groups; `cwdOf` reads `lsof` and `startOf` reads `ps`, so both return `null` where those are missing (Windows, minimal containers). `isAlive` and `spawnGroup` work everywhere.
Process groups and process facts. **POSIX only for groups:** `killGroup` throws on Windows, which has no process groups; `cwdOf` reads `/proc` on Linux and `lsof` elsewhere, `startOf` reads `ps`, so both return `null` where those are missing (Windows, minimal containers). `isAlive` and `spawnGroup` work everywhere.

#### `spawnGroup(cmd, args, { cwd, env })`
#### `spawnGroup(cmd, args, { cwd?, env? })`

`spawn` with `detached: true` and piped stdio: the child leads a new process group, so one signal reaches every grandchild that stays in it. Returns the `ChildProcess`.

#### `killGroup(pid, graceMs = 10_000, snapshot?)`

SIGTERM to the group led by `pid`, up to `graceMs` for it to exit, then SIGKILL — to that group *and* to every detached group started beneath it (found with `ps -axo pid=,ppid=,pgid=`, before the SIGTERM and again after the grace). Groups merely shared with the target are left alone. If `ps` fails (EAGAIN at the process limit), the target group is still killed. Rejects for `pid` 0, 1, negative or non-integer, which `kill(2)` would read as "my group" or "every process".
SIGTERM to the group led by `pid` *and* to every detached group started beneath it, up to `graceMs` for all of them to exit, then SIGKILL to the survivors and to any detached group the leader started during the grace (found with `ps -axo pid=,ppid=,pgid=`, before the SIGTERM and again after the grace). Groups merely shared with the target are left alone. If `ps` fails (EAGAIN at the process limit), the target group is still killed. Rejects for `pid` 0, 1, negative or non-integer, which `kill(2)` would read as "my group" or "every process".

#### `isAlive(pid)`, `cwdOf(pid)`, `startOf(pid)`

Expand All @@ -154,13 +171,13 @@ startOf(process.pid) // 'Sat Oct 17 00:51:27 2026'

#### `new Watchdog({ wallMs, stallMs }, onFire, now?)`

Call `poke()` on every sign of life and `check()` on a timer. `onFire('timed_out' | 'stalled')` runs once, on the first check at or past a limit (a 45-minute wall clock fires at minute 45). `setLimits()` applies from the next check, `stop()` disarms it, and `now` is injectable for tests.
Call `poke()` on every sign of life and `check()` on a timer. `onFire('timed_out' | 'stalled')` runs once, on the first check at or past a limit (a 45-minute wall clock fires at minute 45). `setLimits()` applies from the next check, `stop()` disarms it, and `now` is injectable for tests. The default clock is monotonic (`performance.now()`): an NTP step neither fires nor delays it, and time the machine spends asleep does not count, so waking a laptop does not report every child as stalled.

### `clikit/lock`

#### `acquireLock(path)`

Creates `path` with `O_EXCL` and writes this process's pid; returns `{ release() }`. The directory is created owner-only (`0700`) if needed. A file naming a dead pid, or holding no pid (a crash between create and write), is stale and replaced. A live holder — including one owned by another user (`EPERM`) — throws `LockHeld`.
Writes this process's pid to a private file and hard-links it into place, so the lock never exists half-written and exactly one of two racing runs wins; returns `{ release() }`. The directory is created owner-only (`0700`) if needed. A file naming a dead pid, or holding no pid, is stale and replaced: it is renamed aside first and put back if it turns out to be another run's fresh lock, so two runs clearing one stale lock cannot both win. A live holder — including one owned by another user (`EPERM`) — throws `LockHeld`.

#### `LockHeld`

Expand All @@ -183,7 +200,7 @@ withoutGitVars({ PATH: '/usr/bin', GIT_DIR: '/repo/.git', GIT_INDEX_FILE: 'x' })

#### `stripAnsi(text, { caret? })`

Removes CSI (colour, cursor), OSC (titles, hyperlinks) and two-byte escapes. With `caret: true` it also removes escapes written in caret notation (`^[[31m`), which is how `gh` prints them into a pipe. That stays opt-in because elsewhere `^[` is content.
Removes CSI (colour, cursor), OSC (titles, hyperlinks) and short escapes such as `tput sgr0`'s `ESC ( B`, `ESC 7` / `ESC 8` and `ESC =`. With `caret: true` it also removes escapes written in caret notation (`^[[31m`), which is how `gh` prints them into a pipe. That stays opt-in because elsewhere `^[` is content.

```ts
stripAnsi('^[[31merror^[[0m \x1B[1mbold\x1B[22m', { caret: true }) // 'error bold'
Expand All @@ -202,6 +219,8 @@ errnoCode(err) // 'ENOENT' for fs errors; undefined for a numeric exit code

## Recipes

Runnable versions live in [`examples/`](./examples): each one prints what it does and asserts the result, and CI runs them against the build on every platform. Try them with `pnpm build && pnpm examples`, or one at a time with `node examples/watchdog.mjs`.

### Run a CLI with a watchdog and parse its JSON answer

```ts
Expand All @@ -210,7 +229,7 @@ import { killGroup, spawnGroup } from 'clikit/proc'
import { Watchdog } from 'clikit/watchdog'

async function runJson(cmd: string, args: string[]): Promise<unknown> {
const child = spawnGroup(cmd, args, { cwd: process.cwd(), env: process.env as Record<string, string> })
const child = spawnGroup(cmd, args)
let stdout = ''
let reason: string | undefined

Expand Down Expand Up @@ -333,21 +352,24 @@ Use jsonrepair or partial-json when the JSON itself is damaged or still streamin
| clikit | Alternative | What differs |
| --- | --- | --- |
| `spawnGroup` + `killGroup` | [tree-kill](https://www.npmjs.com/package/tree-kill), [fkill](https://www.npmjs.com/package/fkill), [execa](https://www.npmjs.com/package/execa) | tree-kill signals each pid of the tree once and supports Windows; fkill kills by pid, name or port across platforms; execa is a full process runner with timeouts and `forceKillAfterDelay`. `killGroup` is POSIX-only, but follows grandchildren that started their own detached groups, re-scans after the grace period and refuses `kill(0)` / `kill(-1)`. |
| `Watchdog` | [p-timeout](https://www.npmjs.com/package/p-timeout), execa's `timeout` | Those bound the total time. `Watchdog` adds a stall limit that resets on every sign of life, and leaves the reaction to you. |
| `Watchdog` | [p-timeout](https://www.npmjs.com/package/p-timeout), `AbortSignal.timeout`, execa's `timeout` | Those bound the total time. `Watchdog` adds a stall limit that resets on every sign of life, and leaves the reaction to you. |
| `acquireLock` | [proper-lockfile](https://www.npmjs.com/package/proper-lockfile), [lockfile](https://www.npmjs.com/package/lockfile) | proper-lockfile uses mtime-refreshed lock directories and works over network file systems; lockfile retries and waits. `acquireLock` is a synchronous pidfile that checks whether the holder is alive. |
| `stripAnsi` | [strip-ansi](https://www.npmjs.com/package/strip-ansi) | strip-ansi covers more escape variants. clikit adds opt-in caret notation (`^[[31m`) for `gh` logs. |
| `stripAnsi` | [strip-ansi](https://www.npmjs.com/package/strip-ansi), Node's `util.stripVTControlCharacters` | Both cover 8-bit C1 escapes as well; the built-in needs no install. clikit adds opt-in caret notation (`^[[31m`) for `gh` logs. |
| `errorMessage`, `errnoCode` | [serialize-error](https://www.npmjs.com/package/serialize-error) | serialize-error turns a whole error into a plain object and back. These two read the message and the errno name safely from any thrown value. |
| `withoutSecrets`, `withoutGitVars` | [env-var](https://www.npmjs.com/package/env-var) | env-var reads and validates variables. These filter what a child inherits. |

## Limits

- `lastJson` finds objects, arrays and `null` — not bare numbers, strings or booleans, which look like ordinary log lines. Only the value that *ends* the output counts, and on the last line only the first `[` or `{` is tried.
- `lastJsonObject` ends at the last `}` in the text. A stray `}` in prose after the answer hides it. It walks back over at most 1,024 opening braces, so an answer holding more than about a thousand nested objects is not found; that cap keeps hostile input (200,000 nested levels) under a second.
- A `validate` guard can only reject: `lastJsonObject` does not then look for an earlier, separate object.
- `lastJson` finds objects, arrays and `null` — not bare numbers, strings or booleans, which look like ordinary log lines. Only the value that *ends* the output counts; on the last line every `[` or `{` is tried, left to right.
- `lastJsonObject` tries at most 1,024 brace pairs in all, so an answer holding more than about a thousand nested objects, or buried under a thousand braces of later prose, is not found; that cap keeps hostile input (200,000 nested levels) under a second.
- `killGroup` is POSIX-only and needs `ps`; `cwdOf` needs `lsof`.
- `acquireLock` is for one machine. It does not wait or retry, and it is not safe on network file systems.
- `acquireLock` is for one machine. It does not wait or retry, and it is not safe on network file systems. Clearing a stale lock is safe between two runs; three runs racing over one stale lock in the same few microseconds can still end with two holders, because Node has no `rename` that refuses to overwrite.
- `failureSignature` is tuned for GitHub Actions, pnpm, turbo, vitest, tsc and Playwright output; other tools fall back to their last five lines.

## Stability

clikit is pre-1.0. It follows [semver](https://semver.org), so until 1.0 a minor release (`0.x`) may change an API; every change is listed in the [changelog](./CHANGELOG.md). Pin a minor range (`~0.1.0`) if you need to stay put.

## Contributing

Issues and pull requests are welcome at [github.com/devmaxxx/clikit](https://github.com/devmaxxx/clikit).
Expand All @@ -356,11 +378,12 @@ Issues and pull requests are welcome at [github.com/devmaxxx/clikit](https://git
pnpm install
pnpm build
pnpm test
pnpm examples
pnpm lint
pnpm typecheck
```

Tests pin behaviour, not implementation: a new case should describe what a caller relies on.
Tests pin behaviour, not implementation: a new case should describe what a caller relies on. CI runs lint, typecheck, the tests and the examples for every pull request: on Linux, macOS and Windows with Node 24, and on Linux with Node 22, the oldest supported. Add a line to the [changelog](./CHANGELOG.md) under the unreleased version.

## License

Expand Down
7 changes: 7 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,11 @@ import antfu from '@antfu/eslint-config'

export default antfu({
type: 'lib',
}, {
// Examples are scripts a reader runs: they print what they do and await at the top level.
files: ['examples/**'],
rules: {
'no-console': 'off',
'antfu/no-top-level-await': 'off',
},
})
22 changes: 22 additions & 0 deletions examples/ci-signature.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Tell "the same CI failure again" from "a different failure" across reruns.
import assert from 'node:assert/strict'
import { failureSignature } from 'clikit/log'

// What `gh run view --log-failed` prints: job, step and a timestamp that changes on every run.
function log(at, assertion) {
return [
`unit\tRun tests\t${at}1Z \x1B[31m FAIL \x1B[39m src/sum.test.ts > adds (12ms)`,
`unit\tRun tests\t${at}2Z ${assertion} at /home/runner/work/app/src/sum.ts:10:5`,
`unit\tRun tests\t${at}3Z ##[error]Process completed with exit code 1.`,
].join('\n')
}

const first = failureSignature(log('2026-10-06T17:44:02.', 'AssertionError: expected 3 to be 4'))
const rerun = failureSignature(log('2026-10-06T18:02:51.', 'AssertionError: expected 3 to be 4'))
const other = failureSignature(log('2026-10-06T18:30:07.', 'TypeError: sum is not a function'))

console.log('first run →', first)
console.log('rerun →', rerun)
console.log('other bug →', other)
assert.equal(first, rerun)
assert.notEqual(first, other)
16 changes: 16 additions & 0 deletions examples/env.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Start a child without the credentials and git bindings this process carries.
import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
import process from 'node:process'
import { withoutGitVars, withoutSecrets } from 'clikit/env'

const parent = { ...process.env, GH_TOKEN: 'ghp_example', GIT_DIR: '/elsewhere/.git', GIT_SSH_COMMAND: 'ssh -i key' }
const env = withoutGitVars(withoutSecrets(parent))

const seen = JSON.parse(execFileSync(process.execPath, ['-e', String.raw`
const { GH_TOKEN, GIT_DIR, GIT_SSH_COMMAND } = process.env
console.log(JSON.stringify({ GH_TOKEN, GIT_DIR, GIT_SSH_COMMAND }))
`], { env, encoding: 'utf8' }))

console.log('the child sees', seen)
assert.deepEqual(seen, { GIT_SSH_COMMAND: 'ssh -i key' })
30 changes: 30 additions & 0 deletions examples/json.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Read the JSON answer a CLI prints after its progress lines, and the object a model wraps in prose.
import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
import process from 'node:process'
import { lastJson, lastJsonObject } from 'clikit/json'

// Stands in for `pnpm ls --json`: progress, colour and a CRLF pretty-printed answer, all on stdout.
const cli = String.raw`
process.stdout.write('Progress: resolved 412, reused 410, downloaded 2\n')
process.stdout.write('\x1B[32m✔\x1B[39m Done in 1.2s\r\n')
process.stdout.write(JSON.stringify({ name: 'app', version: '1.4.0' }, null, 2).replace(/\n/g, '\r\n') + '\r\n')
`
const stdout = execFileSync(process.execPath, ['-e', cli], { encoding: 'utf8' })

const pkg = lastJson(stdout)
console.log('lastJson →', pkg)
assert.deepEqual(pkg, { name: 'app', version: '1.4.0' })

const reply = [
'Sure! Here is the review:',
'```json',
'{ "verdict": "approve", "issues": [] }',
'```',
'Close the block with `}` if you edit it.',
].join('\n')

const isReview = v => typeof v === 'object' && v !== null && 'verdict' in v
const review = lastJsonObject(reply, isReview)
console.log('lastJsonObject →', review)
assert.deepEqual(review, { verdict: 'approve', issues: [] })
32 changes: 32 additions & 0 deletions examples/lock.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Keep one copy of a daemon running, and recover from a lock left behind by a crash.
import assert from 'node:assert/strict'
import { spawnSync } from 'node:child_process'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import process from 'node:process'
import { acquireLock, LockHeld } from 'clikit/lock'

const dir = mkdtempSync(join(tmpdir(), 'clikit-example-'))
const path = join(dir, 'daemon.lock')

try {
const lock = acquireLock(path)
console.log(`first run holds ${path}`)

assert.throws(() => acquireLock(path), (e) => {
console.log(`second run: ${e.message}`)
return e instanceof LockHeld && e.pid === process.pid
})

lock.release()

// A crashed run leaves its pid behind; that process is gone, so the lock is stale and taken over.
const crashed = spawnSync(process.execPath, ['-e', '']).pid
writeFileSync(path, String(crashed))
acquireLock(path).release()
console.log(`a lock left by dead pid ${crashed} was taken over`)
}
finally {
rmSync(dir, { recursive: true, force: true })
}
Loading
Loading