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
18 changes: 18 additions & 0 deletions .github/actions/setup-rootless-podman/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
name: Set up rootless Podman
description: Prepare an unprivileged Podman runner for mandatory shell E2E tests
runs:
using: composite
steps:
- name: Install Podman prerequisites
shell: bash
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install --yes podman uidmap slirp4netns
if [[ -z "$(getsubids "$USER" 2>/dev/null)" ]]; then
sudo usermod --add-subuids 1000000-1065535 "$USER"
fi
if [[ -z "$(getsubids -g "$USER" 2>/dev/null)" ]]; then
sudo usermod --add-subgids 1000000-1065535 "$USER"
fi
[[ "$(podman info --format '{{.Host.Security.Rootless}}')" == true ]]
87 changes: 79 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,25 @@ on:
permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

env:
GOMAXPROCS: '4'
GOFLAGS: '-p=2'

jobs:
lint:
name: Lint
env:
MISE_ENV: ci-lint
GOMAXPROCS: ${{ startsWith(matrix.os, 'macos-') && '2' || '4' }}
GOMEMLIMIT: ${{ startsWith(matrix.os, 'macos-') && '4GiB' || 'off' }}
strategy:
fail-fast: false
matrix:
os: [ ubuntu-latest, macos-latest, windows-latest ]
os: [ ubuntu-latest, macos-26-intel, windows-latest ]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout code
Expand Down Expand Up @@ -64,11 +74,12 @@ jobs:
mise-env: ci-minimum
- version: stable
mise-env: ci-stable
os: [ ubuntu-latest, macos-latest, windows-latest ]
os: [ ubuntu-latest, macos-26-intel, windows-latest ]
runs-on: ${{ matrix.os }}
env:
MISE_ENV: ${{ matrix.go.mise-env }}
CLI4G_CI: '1'
GOMAXPROCS: ${{ startsWith(matrix.os, 'macos-') && '2' || '4' }}
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
Expand All @@ -93,6 +104,10 @@ jobs:
- name: Test
run: mise run test:go

- name: Cross-build Darwin ARM64
if: runner.os == 'macOS'
run: mise run test:go:darwin-arm64

- name: Build and smoke-test examples
run: mise run test:examples:examples

Expand All @@ -117,6 +132,36 @@ jobs:
retention-days: 1
if-no-files-found: warn

shell-e2e:
name: Shell E2E (rootless Podman)
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7

- name: Install mise
uses: jdx/mise-action@9b200bcecbfd6303e69a4a261b2e09e3757440a7 # v5.2.0
with:
version: 2026.9.18
install: true
install_args: --locked

- name: Set up rootless Podman
uses: ./.github/actions/setup-rootless-podman

- name: Test Bash, Fish, Nushell, PowerShell and Zsh in real terminals
run: mise run test:shells --engine podman --coverage

- name: Store shell coverage
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: tests-shells-${{ github.run_number }}
path: |
profile.shells.cov
profile.shell-runner.cov
retention-days: 1
if-no-files-found: error

quality:
name: Quality gate
runs-on: ubuntu-latest
Expand Down Expand Up @@ -147,16 +192,23 @@ jobs:

coverage:
name: Coverage
needs: test
needs: [ test, shell-e2e ]
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7

- name: Install mise
uses: jdx/mise-action@9b200bcecbfd6303e69a4a261b2e09e3757440a7 # v5.2.0
with:
version: 2026.9.18
install: true
install_args: --locked

- name: Download coverage profiles
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: tests-go-*
pattern: tests-*
path: coverage

- name: Collect coverage profiles
Expand All @@ -181,7 +233,24 @@ jobs:
profiles+=("$profile")
done
done
printf 'files=%s\n' "${profiles[*]}" >> "$GITHUB_OUTPUT"

shell_directories=(coverage/tests-shells-*)
if [[ ${#shell_directories[@]} -eq 0 ]]; then
echo "::error::No shell E2E coverage artifact was downloaded."
exit 1
fi
for directory in "${shell_directories[@]}"; do
for filename in profile.shells.cov profile.shell-runner.cov; do
profile="$directory/$filename"
if [[ ! -s "$profile" ]] || [[ $(wc -l < "$profile") -le 1 ]]; then
echo "::error::Missing or empty shell coverage profile: $profile"
exit 1
fi
profiles+=("$profile")
done
done
mise exec -- go run ./internal/coveragemerge -output profile.merged.cov "${profiles[@]}"
printf 'files=%s\n' 'profile.merged.cov' >> "$GITHUB_OUTPUT"

- name: Send coverage
uses: coverallsapp/github-action@8d6379e14d29928660c4ba802d8e85393440b329 # v2
Expand All @@ -193,7 +262,7 @@ jobs:

finish:
name: Finish
needs: [ lint, quality, test, coverage ]
needs: [ lint, quality, test, shell-e2e, coverage ]
if: ${{ always() }}
runs-on: ubuntu-latest
steps:
Expand All @@ -203,12 +272,14 @@ jobs:
LINT_RESULT: ${{ needs.lint.result }}
QUALITY_RESULT: ${{ needs.quality.result }}
TEST_RESULT: ${{ needs.test.result }}
SHELL_E2E_RESULT: ${{ needs.shell-e2e.result }}
COVERAGE_RESULT: ${{ needs.coverage.result }}
run: |
if [[ "$LINT_RESULT" != "success" ||
"$QUALITY_RESULT" != "success" ||
"$TEST_RESULT" != "success" ||
"$TEST_RESULT" != "success" ||
"$SHELL_E2E_RESULT" != "success" ||
"$COVERAGE_RESULT" != "success" ]]; then
echo "::error::Required CI jobs did not all succeed: lint=${LINT_RESULT}, quality=${QUALITY_RESULT}, test=${TEST_RESULT}, coverage=${COVERAGE_RESULT}"
echo "::error::Required CI jobs did not all succeed: lint=${LINT_RESULT}, quality=${QUALITY_RESULT}, test=${TEST_RESULT}, shell-e2e=${SHELL_E2E_RESULT}, coverage=${COVERAGE_RESULT}"
exit 1
fi
4 changes: 4 additions & 0 deletions .github/workflows/update-mise-dependencies.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ jobs:
echo "changed=true" >> "$GITHUB_OUTPUT"
fi

- name: Set up rootless Podman
if: steps.update.outputs.changed == 'true'
uses: ./.github/actions/setup-rootless-podman

- name: Verify updates
if: steps.update.outputs.changed == 'true'
run: mise run check:full
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
profile.cov
profile.cov.full
profile.examples.cov
profile.shells.cov
profile.shell-runner.cov
profile.merged.cov
/mise.local.toml
/mise.local.lock
/.mise/locks/mise.local/
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
# cli4g

Typed command-line applications for Go: flags, arguments, subcommands,
environment values, automatic help, and invocation-owned resources.
environment values, automatic help, shell completion, and invocation-owned resources.

## Install

Expand Down Expand Up @@ -78,7 +78,7 @@ Qualitative ratings for the [compared versions](_benchmarks):
| Lifecycle and resource cleanup | ★★★ | ★★☆ | ★★☆ | ★★☆ | — |
| Compile once, isolated concurrent parses | ★★★ | — | — | — | — |
| Per-parse schema profiles | ★★★ | — | — | — | — |
| Shell completion | — | ★★★ | ★★★ | — | — |
| Shell completion | ★★★ | ★★★ | ★★★ | — | — |
| Setup/compile + first invocation speed[^performance] | ★★☆ | ★★☆ | ★★★ | ★☆☆ | ★★★ |
| Parse/execute speed on reuse[^performance] | ★★★ | ★★☆ | ★★★ | ★☆☆ | ★★★ |

Expand Down
1 change: 1 addition & 0 deletions _examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Run from the repository root with `mise example <name> [arguments...]`.
| [validation-errors](validation-errors) | Explicit validation markers, technical errors, and preserved causes |
| [environment](environment) | CLI/environment/default priority |
| [enums](enums) | Typed text/Value enums, enum tags, and choices in help and errors |
| [completion](completion) | The `ship` deployment tool with automatic completion and inventory-aware namespace/service suggestions |
| [profiles](profiles) | Per-parse typed flag, argument, and command variants |
| [repeatable-flags](repeatable-flags) | Collecting repeated flag values in a slice |
| [date-time](date-time) | Date/DateTime codecs, custom layouts, and repeatable dates |
Expand Down
80 changes: 80 additions & 0 deletions _examples/completion/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Ship: plan a service deployment

`ship` plans service deployments to development or production namespaces. A local inventory makes the example runnable without a cluster; the command prints its deployment plan.

The options and `deploy` command are defined once in `main.go`. Completion uses that normal CLI definition: `WithCompletion(true)` selects the default public command and request entry, and `codec.WithCompletionHandler` adds inventory lookups to ordinary string codecs.

## Run the command

Run from the repository root:

```shell
mise example completion deploy api
# Plan: api -> development/default (replicas=1, wait=false)

mise example completion deploy checkout --context production --namespace payments --replicas 3 --wait
# Plan: checkout -> production/payments (replicas=3, wait=true)
```

Omitting `--context` uses `CLI4G_SHIP_CONTEXT`, then the `development` default.

## Try interactive completion

Build the example as `ship` (`ship.exe` on Windows), then make that executable available on `PATH`. The directory is named after the demonstrated feature; the application itself is a deployment tool:

```shell
go -C _examples/completion build -o ship .
```

In **Bash 4.4+**, with a configured `bash-completion` loader:

```bash
ship completion install bash
# At the prompt, type and press Tab:
# ship --context production deploy checkout --namespace pa<Tab>
```

In **Nushell 0.114+**:

```nu
ship completion install nu
# Start a new interactive Nu session, then type and press Tab:
# ship --context production --namespace payments deploy ch<Tab>
```

Nu 0.114.1 supplies only input before the cursor to the completer. Put dependency flags before the value you complete; later arguments stay in the editor but cannot affect suggestions.

In **Windows PowerShell 5.1+ or PowerShell 7+**:

```powershell
ship completion install powershell
# Start a new PowerShell session, then type and press Tab:
# ship --context production --namespace payments deploy ch<Tab>
```

In **Zsh 5.8+**:

```zsh
ship completion install zsh
# Start a new Zsh session, then type and press Tab:
# ship --context production --namespace payments deploy ch<Tab>
```

In **Fish 3.6+**:

```fish
ship completion install fish
# Start a new Fish session, then type and press Tab:
# ship --context production --namespace payments deploy ch<Tab>
```

The standard handler completes `deploy`, flag names, the `development`/`production` context enum, and bool values automatically. The two callbacks in `inventory.go` add domain knowledge:

- Namespace suggestions depend on the context and the selected service.
- Service suggestions depend on the context and namespace.

cli4g supplies the input context available from the shell, filters prefixes, and handles shell output. The callbacks return plain values; the same inventory is used by the normal deployment handler. A real client lookup would use the supplied cancellation context. Installed adapters continue to work after binary updates.

The public setup command is the ordinary `DefaultCompletionCommand`, initially named `completion`. It uses the same lifecycle and parent requirements as `deploy`. Applications can replace it with `WithCompletionCommand(...)`, including a fresh `NewCompletionCommand().WithName("shells")`. Only the separate request entry (initially `__complete`) bypasses normal validation and initialization. Configure it with `WithCompletionRequestCommandName`; empty disables it.

See the [GoDoc guide](https://pkg.go.dev/github.com/echocat/cli4g#hdr-Shell_completion) for API and setup details.
12 changes: 12 additions & 0 deletions _examples/completion/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
module github.com/echocat/cli4g/_examples/completion

go 1.27.0

replace github.com/echocat/cli4g => ../..

require github.com/echocat/cli4g v0.0.0

require (
golang.org/x/sys v0.49.0 // indirect
golang.org/x/term v0.47.0 // indirect
)
8 changes: 8 additions & 0 deletions _examples/completion/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
Loading
Loading