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
38 changes: 38 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: tests

on:
push:
branches: [main]
pull_request:

permissions:
contents: read

jobs:
test:
name: test (go ${{ matrix.go }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
go: ['1.21', '1.22', '1.23', '1.24', '1.25', 'stable']
steps:
- uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go }}
check-latest: true

- name: go vet
run: go vet ./...

- name: staticcheck
uses: dominikh/staticcheck-action@v1
with:
version: latest
install-go: false

- name: go test
run: go test -race -count=1 ./...
25 changes: 25 additions & 0 deletions .github/workflows/vulns.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: vulns

on:
push:
branches: [main]
pull_request:

permissions:
contents: read

jobs:
govulncheck:
name: govulncheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: 'stable'
check-latest: true

- name: Run govulncheck
uses: golang/govulncheck-action@v1
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) Roscoe Skeens <web@rskeens.com>

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
264 changes: 263 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,263 @@
# tidycli
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="assets/logo-dark.png">
<source media="(prefers-color-scheme: light)" srcset="assets/logo-light.png">
<img alt="tidycli" src="assets/logo-light.png">
</picture>
</p>

[![CI](https://github.com/RSkeens/tidycli/actions/workflows/tests.yml/badge.svg)](https://github.com/RSkeens/tidycli/actions/workflows/tests.yml) [![Go Reference](https://pkg.go.dev/badge/github.com/RSkeens/tidycli.svg)](https://pkg.go.dev/github.com/RSkeens/tidycli) ![GitHub License](https://img.shields.io/github/license/RSkeens/tidycli) [![Go Report Card](https://goreportcard.com/badge/github.com/RSkeens/tidycli)](https://goreportcard.com/report/github.com/RSkeens/tidycli)

`tidycli` is a tiny and dependency free router for go clis that need subcommands
without adopting a rich framework. It provides cli handling without ceremony.

As a positional subcommand router, there is no global state or opinionated
bootstrapping. It does all this in under 200 lines of executable code with
zero dependencies while getting out the way of stdlib `flag`.

Prettier than stdlib, simpler than [urfave/cli](https://github.com/urfave/cli)
or [kong](https://github.com/alecthomas/kong) and less framework heavy
than [Cobra](https://github.com/spf13/cobra). `tidycli` focuses on excellent
defaults, clean help management, a tiny api surface and fast setup.

Perfectly structured:

- Positional subcommands.
- Stdlib `flag` compatibility.
- Zero dependencies.
- Explicit application wiring.
- Command functions that receive a context.
- Tiny API that is easy to read and vendor.

Describe your cli as a tree of commands mapped by arg keyword. Built in per
command minimum arg enforcement and usage strings surfaced as errors.
It never dictates how your app should bootstrap, configure logging or parse
global flags. App state is supplied to commands by closing over it from
the caller package.

## Install

```
go get github.com/rskeens/tidycli
```

## Smallest App

The smallest useful `tidycli` app is one `Sub` map with one `Fn`:

```go
package main

import (
"context"
"errors"
"flag"
"fmt"
"log"

"github.com/rskeens/tidycli"
)

var errHello = errors.New("usage: hello <name>")

func main() {
flag.Parse()

if err := tidycli.Run(context.Background(), tidycli.Sub{
"hello": {Help: errHello, Min: 1, Fn: func(_ context.Context, args []string) error {
fmt.Println("hello,", args[0])

return nil
}},
}); err != nil {
log.Fatal(err)
}
}
```

```
$ go run . hello world
hello, world

$ go run . hello
usage: hello <name>
```

That is the entire api surface for a single level cli! A `Sub` keyed by arg,
`Help` error, `Min` count and `Fn`. Everything else in this readme is
about scaling that pattern up to nested commands and multi package layouts.

## Comparison

`tidycli` exists because alternatives are either too limited (stdlib `flag`
has no subcommand routing) or too opinionated (frameworks that own `main`,
the flag parser or help output). How it compares:

| Feature | `tidycli` | stdlib `flag` | [`urfave/cli`][urfave] | [`kong`][kong] | [`cobra`][cobra] |
| --- | --- | --- | --- | --- | --- |
| Positional subcommand routing | ✅ | ❌ | ✅ | ✅ | ✅ |
| Works with stdlib `flag` | native | native | own parser | own parser | via [`pflag`][pflag] |
| Reflection / struct tags required | ❌ | ❌ | ❌ | ✅ | ❌ |
| Runtime deps | 0 | — | 0 | 0 | 4 |
| Implementation size | <200 LOC (`cli.go` + `errs.go`) | — | ~4,900 LOC across 42 files | ~5,500 LOC across 31 files | ~4,900 LOC across 19 files |
| Role | router | parser | framework | declarative parser | framework |

Dependency counts are from current default branch `go.mod` `require` block,
excluding modules imported only from `_test.go` files. `tidycli`'s own `go.mod`
has an empty `require` block.

Implementation size figures are measured with [`gocloc`](https://github.com/hhatto/gocloc)
over non test `.go` files (counting code lines only, excluding blanks and comments).
Intended as ballpark rather than exact totals.

[urfave]: https://github.com/urfave/cli
[kong]: https://github.com/alecthomas/kong
[cobra]: https://github.com/spf13/cobra
[pflag]: https://github.com/spf13/pflag

## Project Layout

Example app at [`example/`](./example) is laid out using one package per command:

```
example/
├── cmd/hello-world/
│ ├── cli.go // cmds arg:cmd layout
│ └── main.go // entry
└── pkg/cli/
├── help/help.go // central usage errors
├── hello/
│ ├── cmd.go // hello.Cmd() returns *tidycli.Cmd
│ └── hello.go // hello.Do leaf
└── greet/
├── cmd.go // greet.Cmd() with Sub for hi / bye
└── greet.go // greet.Hi / greet.Bye leaves
```

`cmd/hello-world/cli.go` arg:command map:

```go
// cmd/hello-world/cli.go
package main

import (
"github.com/rskeens/tidycli"
"github.com/rskeens/tidycli/example/pkg/cli/greet"
"github.com/rskeens/tidycli/example/pkg/cli/hello"
)

// cmds stores arg:cmd layout.
var cmds = tidycli.Sub{
"hello": hello.Cmd(),
"greet": greet.Cmd(),
}
```

`cmd/hello-world/main.go` wires `flag.Parse` to `tidycli.Run`:

```go
// cmd/hello-world/main.go
package main

import (
"context"
"flag"
"log"

"github.com/rskeens/tidycli"
)

func main() {
flag.Parse()

if err := tidycli.Run(context.Background(), cmds); err != nil {
log.Fatal(err)
}
}
```

Each command package exposes a `Cmd()` constructor which returns `*tidycli.Cmd`
alongside leaf functions:

```go
// pkg/cli/hello/cmd.go
package hello

import (
"fmt"

"github.com/rskeens/tidycli"
"github.com/rskeens/tidycli/example/pkg/cli/help"
)

// Cmd returns the cmd.
func Cmd() *tidycli.Cmd {
return &tidycli.Cmd{
Help: help.ErrHello,
Min: 1,
Fn: Do,
}
}

// pkg/cli/hello/hello.go
func Do(_ context.Context, args []string) error {
fmt.Println("hello,", args[0])

return nil
}
```

Parents with subcommands keep `Sub` map inline in the same `Cmd()` constructor.

Run:

```
go run ./example/cmd/hello-world hello world
go run ./example/cmd/hello-world greet hi alice
go run ./example/cmd/hello-world
```

## Concepts

### Types

| Type | Description |
|---------|-------------|
| `Cmd` | Single command node. |
| `Sub` | `map[string]*Cmd` describing one level of the command tree. |
| `Fn` | `func(ctx context.Context, args []string) error` called once routing to resolve leaf. Context passed from `Run` so commands can honour cancel and deadlines. To give a command access to app state, close over it when constructing `Fn`. |

### `Cmd` Fields

| Field | Description |
|---------|-------------|
| `Help` | Usage string returned as error when args are missing or command is non leaf. |
| `Min` | Minimum number of trailing args. |
| `Sub` | Command. |
| `Flags` | When non nil, `*flag.FlagSet` parsed against args once command resolves. |
| `Fn` | Invoked func. |

### Routing and Execution

| Call | Description |
|--------------------------|-------------|
| `Run(ctx, sub)` | Calls `sub.Route()` then `Fn` with `ctx` and trailing args. |

### Invalid Returns

| Result | Condition |
|-------------------|-----------|
| `ErrArgNone` | No args given. Returns without writing to stderr, caller decides how (or whether) to render usage. |
| `ErrArgInvalid` | Arg does not match (wrapped with unknown keyword). |
| Command's `Help` | Non leaf or fewer than `Min` args given. |

`sub.Print()` writes usage to `os.Stdout`. `sub.Fprint(w)` writes to arbitrary
`io.Writer` (tests, redirection to stderr, etc).

## Helps

The [`help`](./help) subpackage offers small types (`Help`, `Helps`) for
apps which want to manage usage strings as a uniform collection.
Their use is optional.

Hopefully this software can somehow bring a bit of peace to this troubled world.
17 changes: 17 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Security Policy

Security is considered of greater importance than any other aspect of my software. Any reported vulnerability,
regardless of severity is escalated to immediate priority.

## Reporting a vulnerability

Please send the report to `web@rskeens.com`

We kindly request that you please **do not create a GitHub Issue** to report a security vulnerability. This policy
is out of respect for users of the affected version.

While not required, a Proof of Concept or other instructions which detail how to reproduce would be immensely appreciated.

You can expect a response within 24 hours. Feedback for security ideas or features is welcome.

Full recognition will be given should the report be valid.
Binary file added assets/logo-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/logo-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading