Introduction
In this tutorial you'll build a real aux4 package from an empty folder: a small greet CLI defined entirely in a JSON manifest. You'll write markdown-based tests for it, measure coverage, lint it, install it locally, and prepare it for publishing to hub.aux4.io.
The package is deliberately tiny so the workflow stays in focus — the same loop scales to any package you build later.
Install the tools
Everything you need is part of the aux4 ecosystem:
aux4 aux4 pkger install aux4/test aux4/lint aux4/package-releaser
- aux4/test — a markdown-based test runner for CLI commands.
- aux4/lint — catches structural mistakes in your
.aux4file. - aux4/package-releaser — versions, builds, installs, and publishes packages.
Lay out the package
An aux4 package lives inside a package/ directory:
mkdir -p greet/package/test
cd greet
Create package/.aux4 — metadata plus two profiles. The main profile is the required entry point; it routes into a greet profile that holds the actual command:
{
"scope": "myorg",
"name": "greet",
"version": "0.0.1",
"description": "A friendly greeter",
"license": "MIT",
"tags": ["greeting", "example"],
"profiles": [
{
"name": "main",
"commands": [
{
"name": "greet",
"execute": ["profile:greet"],
"help": { "text": "Greeting commands" }
}
]
},
{
"name": "greet",
"commands": [
{
"name": "hello",
"execute": ["echo \"Hello, ${name}!\""],
"help": {
"text": "Say hello to someone",
"variables": [
{ "name": "name", "text": "Name to greet", "arg": true, "default": "World" }
]
}
}
]
}
]
}
A few conventions the linter enforces later:
- The
mainprofile is mandatory — it's where aux4 starts. - A profile that a command routes into (
profile:greet) shares the command's name. - Command and profile names use dashes (
send-email); only variable names use camelCase (firstName). - Variables are referenced with
${name}insideexecute. One variable per command can be"arg": true(positional).
The installer also requires a LICENSE and a README.md next to the manifest — without them, install fails with LICENSE file not found or README.md file not found. Add a package/LICENSE (standard MIT text is fine) and a short package/README.md:
# myorg/greet
A friendly greeter.
## greet hello
Say hello to someone.
```bash
aux4 greet hello John
```
Write tests in markdown
aux4 tests are just markdown files ending in .test.md. The runner walks the headings to build scenarios, and reads fenced code blocks whose language tag is a keyword: execute runs a command, expect checks stdout, error checks stderr.
Create package/test/greet__hello.test.md (the __ mirrors the command hierarchy greet hello):
# greet hello
## Greets a named person
```execute
aux4 greet hello John
```
```expect
Hello, John!
```
## Falls back to a default
```execute
aux4 greet hello
```
```expect
Hello, World!
```
Tests in package/test/ call aux4 <command> directly — the runner auto-discovers the package/.aux4 next to them, so there's nothing to wire up.
expect does an exact match by default, but you can relax it by appending modifiers to the tag — expect:partial for substrings and wildcards, expect:ignoreCase, expect:regex, or expect:json to compare JSON regardless of formatting.
Install locally
Install the package so aux4 greet resolves to your code. Run this from the greet/ directory (the one containing package/):
aux4 aux4 releaser install --dir ./package --noBuild true
--noBuild true skips cross-compilation — there's nothing to compile in a pure-.aux4 package.
Building aux4 package myorg/greet 0.0.1-local
Creating zip file myorg_greet_0.0.1-local.zip
+ adding file myorg/greet/.aux4
+ adding file myorg/greet/LICENSE
+ adding file myorg/greet/README.md
+ adding file myorg/greet/test/greet__hello.test.md
Unzipping package myorg greet 0.0.1-local
Loading package myorg greet 0.0.1-local
Installed packages:
✓ myorg/greet 0.0.1-local
The package myorg/greet:0.0.1-local has been installed
The command now works:
aux4 greet hello John
aux4 greet hello
Hello, John!
Hello, World!
Run the tests
From inside package/, run the suite:
aux4 test run test/
PASS engine/aux4.test.js
Test file greet__hello.test.md
1. greet hello
1.1. Greets a named person
✓ 1. should print output (19 ms)
1.2. Falls back to a default
✓ 1. should print output (17 ms)
Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
Snapshots: 0 total
Time: 0.134 s
Want to know how much of your package the tests exercise? Add a coverage gate:
aux4 test coverage test/ --threshold 80
Coverage Report
======================================================================
Package: myorg/greet@0.0.1
main/greet 1/1 steps ████████████ 100% 5ms
greet/hello 1/1 steps ████████████ 100% 5ms
----------------------------------------------------------------------
Summary:
Commands: 2/2 (100%)
Steps: 2/2 (100%)
======================================================================
It exits non-zero if step coverage drops below the threshold — exactly what you want guarding a CI pipeline.
Lint before you ship
The linter flags structural problems — missing main profile, duplicate command names, broken profile: references, dangling ${variables}, naming-convention slips, and more:
aux4 lint run --strict true
No issues found.
--strict true promotes -local version suffixes to errors, so a package only ever installed locally can't accidentally be published. Exit code 0 means no errors; 1 means fix something.
Publish to the hub
When the tests are green and the linter is happy, releasing is a single command. The releaser bumps the version, builds, publishes to the hub, commits, tags, and pushes:
aux4 aux4 releaser release --level patch
--level is patch, minor, or major. The first time you publish you'll authenticate to the hub with your access token.
Prefer to automate it? The official actions run test and publish on every push to main. Add .github/workflows/publish.yml:
name: Publish Package
on:
push:
branches: [main]
workflow_dispatch:
inputs:
level:
description: 'Release level (patch, minor, major)'
default: 'patch'
type: choice
options: [patch, minor, major]
permissions:
contents: write
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: aux4/test-package-action@v1
publish:
needs: [test]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: aux4/publish-package-action@v1
with:
working_directory: package
package_directory: .
level: ${{ inputs.level || 'patch' }}
aux4_token: ${{ secrets.AUX4_ACCESS_TOKEN }}
github_token: ${{ secrets.GITHUB_TOKEN }}
Now every merge to main runs the tests and publishes a new version automatically.
Conclusion
That's the entire arc of building an aux4 package:
- Describe commands in
package/.aux4. - Write
.test.mdtests and run them withaux4 test run. - Gate quality with
aux4 test coverageandaux4 lint run. - Ship with
aux4 aux4 releaser release(or let GitHub Actions do it).
No scaffolding generators, no framework — just a JSON manifest and the same tools the ecosystem uses on itself. Swap echo for any command, add more profiles, and the loop stays identical.