Skip to content

Commit 62fb1b6

Browse files
Mossakaclaude
andcommitted
feat(cli): organize help text with logical option groups
Reorganize CLI options into 6 logical groups (Domain Filtering, Image Management, Container Configuration, Network & Security, API Proxy, Logging & Debug) with section headers in --help output using custom configureHelp formatter. Shorten and standardize option descriptions. Fixes #500 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1fe9e76 commit 62fb1b6

2 files changed

Lines changed: 211 additions & 73 deletions

File tree

src/cli.test.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Command } from 'commander';
2-
import { parseEnvironmentVariables, parseDomains, parseDomainsFile, escapeShellArg, joinShellArgs, parseVolumeMounts, isValidIPv4, isValidIPv6, parseDnsServers, validateAgentImage, isAgentImagePreset, AGENT_IMAGE_PRESETS, processAgentImageOption, processLocalhostKeyword, validateSkipPullWithBuildLocal, validateAllowHostPorts, parseMemoryLimit, validateFormat, validateApiProxyConfig, buildRateLimitConfig, validateRateLimitFlags } from './cli';
2+
import { parseEnvironmentVariables, parseDomains, parseDomainsFile, escapeShellArg, joinShellArgs, parseVolumeMounts, isValidIPv4, isValidIPv6, parseDnsServers, validateAgentImage, isAgentImagePreset, AGENT_IMAGE_PRESETS, processAgentImageOption, processLocalhostKeyword, validateSkipPullWithBuildLocal, validateAllowHostPorts, parseMemoryLimit, validateFormat, validateApiProxyConfig, buildRateLimitConfig, validateRateLimitFlags, formatItem, program } from './cli';
33
import { redactSecrets } from './redact-secrets';
44
import * as fs from 'fs';
55
import * as path from 'path';
@@ -1561,4 +1561,55 @@ describe('cli', () => {
15611561
expect(parseMemoryLimit('0g')).toHaveProperty('error');
15621562
});
15631563
});
1564+
1565+
describe('formatItem', () => {
1566+
it('should format item with description on same line when term fits', () => {
1567+
const result = formatItem('-v', 'verbose output', 20, 2, 2, 80);
1568+
expect(result).toBe(' -v verbose output');
1569+
});
1570+
1571+
it('should format item with description on next line when term is long', () => {
1572+
const result = formatItem('--very-long-option-name-here', 'desc', 10, 2, 2, 80);
1573+
expect(result).toContain('--very-long-option-name-here');
1574+
expect(result).toContain('\n');
1575+
expect(result).toContain('desc');
1576+
});
1577+
1578+
it('should format item without description', () => {
1579+
const result = formatItem('--flag', '', 20, 2, 2, 80);
1580+
expect(result).toBe(' --flag');
1581+
});
1582+
});
1583+
1584+
describe('help text formatting', () => {
1585+
it('should include section headers in help output', () => {
1586+
const help = program.helpInformation();
1587+
expect(help).toContain('Domain Filtering:');
1588+
expect(help).toContain('Image Management:');
1589+
expect(help).toContain('Container Configuration:');
1590+
expect(help).toContain('Network & Security:');
1591+
expect(help).toContain('API Proxy:');
1592+
expect(help).toContain('Logging & Debug:');
1593+
});
1594+
1595+
it('should include usage line', () => {
1596+
const help = program.helpInformation();
1597+
expect(help).toContain('Usage: awf');
1598+
});
1599+
1600+
it('should include program description', () => {
1601+
const help = program.helpInformation();
1602+
expect(help).toContain('Network firewall for agentic workflows');
1603+
});
1604+
1605+
it('should include arguments section', () => {
1606+
const help = program.helpInformation();
1607+
expect(help).toContain('Arguments:');
1608+
});
1609+
1610+
it('should include options section', () => {
1611+
const help = program.helpInformation();
1612+
expect(help).toContain('Options:');
1613+
});
1614+
});
15641615
});

src/cli.ts

Lines changed: 159 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -694,54 +694,129 @@ export function parseVolumeMounts(mounts: string[]): ParseVolumeMountsResult | P
694694
return { success: true, mounts: result };
695695
}
696696

697-
const program = new Command();
697+
export function formatItem(
698+
term: string,
699+
description: string,
700+
termWidth: number,
701+
indent: number,
702+
sep: number,
703+
_helpWidth: number
704+
): string {
705+
const indentStr = ' '.repeat(indent);
706+
const fullWidth = termWidth + sep;
707+
if (description) {
708+
if (term.length < fullWidth - sep) {
709+
return `${indentStr}${term.padEnd(fullWidth)}${description}`;
710+
}
711+
return `${indentStr}${term}\n${' '.repeat(indent + fullWidth)}${description}`;
712+
}
713+
return `${indentStr}${term}`;
714+
}
715+
716+
export const program = new Command();
717+
718+
// Option group markers used by the custom help formatter to insert section headers.
719+
// Each key is the long flag name of the first option in a group.
720+
const optionGroupHeaders: Record<string, string> = {
721+
'allow-domains': 'Domain Filtering:',
722+
'build-local': 'Image Management:',
723+
'env': 'Container Configuration:',
724+
'dns-servers': 'Network & Security:',
725+
'enable-api-proxy': 'API Proxy:',
726+
'log-level': 'Logging & Debug:',
727+
};
698728

699729
program
700730
.name('awf')
701731
.description('Network firewall for agentic workflows with domain whitelisting')
702732
.version(version)
733+
.configureHelp({
734+
formatHelp(cmd, helper): string {
735+
const termWidth = helper.padWidth(cmd, helper);
736+
const helpWidth = (helper as unknown as { helpWidth?: number }).helpWidth ?? 80;
737+
const itemIndent = 2;
738+
const itemSep = 2;
739+
740+
const output: string[] = [];
741+
742+
// Usage line
743+
const usage = helper.commandUsage(cmd);
744+
output.push(`Usage: ${usage}`);
745+
746+
const desc = helper.commandDescription(cmd);
747+
if (desc) {
748+
output.push('');
749+
output.push(desc);
750+
}
751+
752+
// Arguments
753+
const args = helper.visibleArguments(cmd);
754+
if (args.length > 0) {
755+
output.push('');
756+
output.push('Arguments:');
757+
for (const arg of args) {
758+
const term = helper.argumentTerm(arg);
759+
const argDesc = helper.argumentDescription(arg);
760+
output.push(formatItem(term, argDesc, termWidth, itemIndent, itemSep, helpWidth));
761+
}
762+
}
763+
764+
// Options with group headers
765+
const options = helper.visibleOptions(cmd);
766+
if (options.length > 0) {
767+
output.push('');
768+
output.push('Options:');
769+
for (const opt of options) {
770+
const flags = helper.optionTerm(opt);
771+
const optDesc = helper.optionDescription(opt);
772+
const longFlag = opt.long?.replace(/^--/, '');
773+
if (longFlag && optionGroupHeaders[longFlag]) {
774+
output.push('');
775+
output.push(` ${optionGroupHeaders[longFlag]}`);
776+
}
777+
output.push(formatItem(flags, optDesc, termWidth, itemIndent + 2, itemSep, helpWidth));
778+
}
779+
}
780+
781+
return output.join('\n') + '\n';
782+
}
783+
})
784+
785+
// -- Domain Filtering --
703786
.option(
704787
'-d, --allow-domains <domains>',
705788
'Comma-separated list of allowed domains. Supports wildcards and protocol prefixes:\n' +
706-
' github.com - exact domain + subdomains (HTTP & HTTPS)\n' +
707-
' *.github.com - any subdomain of github.com\n' +
708-
' api-*.example.com - api-* subdomains\n' +
709-
' https://secure.com - HTTPS only\n' +
710-
' http://legacy.com - HTTP only\n' +
711-
' localhost - auto-configure for local testing (Playwright, etc.)'
789+
' github.com - exact domain + subdomains (HTTP & HTTPS)\n' +
790+
' *.github.com - any subdomain of github.com\n' +
791+
' api-*.example.com - api-* subdomains\n' +
792+
' https://secure.com - HTTPS only\n' +
793+
' http://legacy.com - HTTP only\n' +
794+
' localhost - auto-configure for local testing (Playwright, etc.)'
712795
)
713796
.option(
714797
'--allow-domains-file <path>',
715-
'Path to file containing allowed domains (one per line or comma-separated, supports # comments)'
798+
'Path to file with allowed domains (one per line, supports # comments)'
716799
)
717800
.option(
718801
'--block-domains <domains>',
719-
'Comma-separated list of blocked domains (takes precedence over allowed domains). Supports wildcards.'
802+
'Comma-separated blocked domains (overrides allow list). Supports wildcards.'
720803
)
721804
.option(
722805
'--block-domains-file <path>',
723-
'Path to file containing blocked domains (one per line or comma-separated, supports # comments)'
806+
'Path to file with blocked domains (one per line, supports # comments)'
724807
)
725808
.option(
726-
'--log-level <level>',
727-
'Log level: debug, info, warn, error',
728-
'info'
729-
)
730-
.option(
731-
'-k, --keep-containers',
732-
'Keep containers running after command exits',
733-
false
734-
)
735-
.option(
736-
'--tty',
737-
'Allocate a pseudo-TTY for the container (required for interactive tools like Claude Code)',
809+
'--ssl-bump',
810+
'Enable SSL Bump for HTTPS content inspection (allows URL path filtering)',
738811
false
739812
)
740813
.option(
741-
'--work-dir <dir>',
742-
'Working directory for temporary files',
743-
path.join(os.tmpdir(), `awf-${Date.now()}`)
814+
'--allow-urls <urls>',
815+
'Comma-separated allowed URL patterns for HTTPS (requires --ssl-bump).\n' +
816+
' Supports wildcards: https://github.com/myorg/*'
744817
)
818+
819+
// -- Image Management --
745820
.option(
746821
'-b, --build-local',
747822
'Build containers locally instead of using GHCR images',
@@ -750,13 +825,13 @@ program
750825
.option(
751826
'--agent-image <value>',
752827
'Agent container image (default: "default")\n' +
753-
' Presets (pre-built, fast):\n' +
754-
' default - Minimal ubuntu:22.04 (~200MB)\n' +
755-
' act - GitHub Actions parity (~2GB)\n' +
756-
' Custom base images (requires --build-local):\n' +
757-
' ubuntu:XX.XX\n' +
758-
' ghcr.io/catthehacker/ubuntu:runner-XX.XX\n' +
759-
' ghcr.io/catthehacker/ubuntu:full-XX.XX'
828+
' Presets (pre-built, fast):\n' +
829+
' default - Minimal ubuntu:22.04 (~200MB)\n' +
830+
' act - GitHub Actions parity (~2GB)\n' +
831+
' Custom base images (requires --build-local):\n' +
832+
' ubuntu:XX.XX\n' +
833+
' ghcr.io/catthehacker/ubuntu:runner-XX.XX\n' +
834+
' ghcr.io/catthehacker/ubuntu:full-XX.XX'
760835
)
761836
.option(
762837
'--image-registry <registry>',
@@ -766,20 +841,22 @@ program
766841
.option(
767842
'--image-tag <tag>',
768843
'Container image tag (applies to both squid and agent images)\n' +
769-
' Image name varies by --agent-image preset:\n' +
770-
' default → agent:<tag>\n' +
771-
' act → agent-act:<tag>',
844+
' Image name varies by --agent-image preset:\n' +
845+
' default → agent:<tag>\n' +
846+
' act → agent-act:<tag>',
772847
'latest'
773848
)
774849
.option(
775850
'--skip-pull',
776-
'Use local images without pulling from registry (requires images to be pre-downloaded)',
851+
'Use local images without pulling from registry (requires pre-downloaded images)',
777852
false
778853
)
854+
855+
// -- Container Configuration --
779856
.option(
780857
'-e, --env <KEY=VALUE>',
781-
'Additional environment variables to pass to container (can be specified multiple times)',
782-
(value, previous: string[] = []) => [...previous, value],
858+
'Environment variable for the container (repeatable)',
859+
(value: string, previous: string[] = []) => [...previous, value],
783860
[]
784861
)
785862
.option(
@@ -789,79 +866,89 @@ program
789866
)
790867
.option(
791868
'-v, --mount <host_path:container_path[:mode]>',
792-
'Volume mount (can be specified multiple times). Format: host_path:container_path[:ro|rw]',
793-
(value, previous: string[] = []) => [...previous, value],
869+
'Volume mount (repeatable). Format: host_path:container_path[:ro|rw]',
870+
(value: string, previous: string[] = []) => [...previous, value],
794871
[]
795872
)
796873
.option(
797874
'--container-workdir <dir>',
798-
'Working directory inside the container (should match GITHUB_WORKSPACE for path consistency)'
875+
'Working directory inside the container'
799876
)
800877
.option(
801878
'--memory-limit <limit>',
802879
'Memory limit for the agent container (e.g., 1g, 2g, 4g, 512m). Default: 2g',
803880
'2g'
804881
)
805882
.option(
806-
'--dns-servers <servers>',
807-
'Comma-separated list of trusted DNS servers. DNS traffic is ONLY allowed to these servers (default: 8.8.8.8,8.8.4.4)',
808-
'8.8.8.8,8.8.4.4'
883+
'--tty',
884+
'Allocate a pseudo-TTY (required for interactive tools like Claude Code)',
885+
false
809886
)
887+
888+
// -- Network & Security --
810889
.option(
811-
'--proxy-logs-dir <path>',
812-
'Directory to save Squid proxy logs to (writes access.log directly to this directory)'
890+
'--dns-servers <servers>',
891+
'Comma-separated trusted DNS servers',
892+
'8.8.8.8,8.8.4.4'
813893
)
814894
.option(
815895
'--enable-host-access',
816-
'Enable access to host services via host.docker.internal. ' +
817-
'Security warning: When combined with --allow-domains host.docker.internal, ' +
818-
'containers can access ANY service on the host machine.',
896+
'Enable access to host services via host.docker.internal',
819897
false
820898
)
821899
.option(
822900
'--allow-host-ports <ports>',
823-
'Comma-separated list of ports or port ranges to allow when using --enable-host-access. ' +
824-
'By default, only ports 80 and 443 are allowed. ' +
825-
'Example: --allow-host-ports 3000 or --allow-host-ports 3000,8080 or --allow-host-ports 3000-3010,8000-8090'
826-
)
827-
.option(
828-
'--ssl-bump',
829-
'Enable SSL Bump for HTTPS content inspection (allows URL path filtering for HTTPS)',
830-
false
831-
)
832-
.option(
833-
'--allow-urls <urls>',
834-
'Comma-separated list of allowed URL patterns for HTTPS (requires --ssl-bump).\n' +
835-
' Supports wildcards: https://github.com/myorg/*'
901+
'Ports/ranges to allow with --enable-host-access (default: 80,443).\n' +
902+
' Example: 3000,8080 or 3000-3010,8000-8090'
836903
)
904+
905+
// -- API Proxy --
837906
.option(
838907
'--enable-api-proxy',
839-
'Enable API proxy sidecar for holding authentication credentials.\n' +
840-
' Deploys a Node.js proxy that injects API keys securely.\n' +
841-
' Supports OpenAI (Codex) and Anthropic (Claude) APIs.',
908+
'Enable API proxy sidecar for secure credential injection.\n' +
909+
' Supports OpenAI (Codex) and Anthropic (Claude) APIs.',
842910
false
843911
)
844912
.option(
845913
'--copilot-api-target <host>',
846-
'Target hostname for GitHub Copilot API requests in the api-proxy sidecar.\n' +
847-
' Defaults to api.githubcopilot.com. Useful for GHES deployments.\n' +
848-
' Can also be set via COPILOT_API_TARGET env var.',
914+
'Target hostname for Copilot API requests (default: api.githubcopilot.com)',
849915
)
850916
.option(
851917
'--rate-limit-rpm <n>',
852-
'Enable rate limiting: max requests per minute per provider (requires --enable-api-proxy)',
918+
'Max requests per minute per provider (requires --enable-api-proxy)',
853919
)
854920
.option(
855921
'--rate-limit-rph <n>',
856-
'Enable rate limiting: max requests per hour per provider (requires --enable-api-proxy)',
922+
'Max requests per hour per provider (requires --enable-api-proxy)',
857923
)
858924
.option(
859925
'--rate-limit-bytes-pm <n>',
860-
'Enable rate limiting: max request bytes per minute per provider (requires --enable-api-proxy)',
926+
'Max request bytes per minute per provider (requires --enable-api-proxy)',
861927
)
862928
.option(
863929
'--no-rate-limit',
864-
'Explicitly disable rate limiting in the API proxy (requires --enable-api-proxy)',
930+
'Disable rate limiting in the API proxy (requires --enable-api-proxy)',
931+
)
932+
933+
// -- Logging & Debug --
934+
.option(
935+
'--log-level <level>',
936+
'Log level: debug, info, warn, error',
937+
'info'
938+
)
939+
.option(
940+
'-k, --keep-containers',
941+
'Keep containers running after command exits',
942+
false
943+
)
944+
.option(
945+
'--work-dir <dir>',
946+
'Working directory for temporary files',
947+
path.join(os.tmpdir(), `awf-${Date.now()}`)
948+
)
949+
.option(
950+
'--proxy-logs-dir <path>',
951+
'Directory to save Squid proxy access.log'
865952
)
866953
.argument('[args...]', 'Command and arguments to execute (use -- to separate from options)')
867954
.action(async (args: string[], options) => {

0 commit comments

Comments
 (0)