From 678dd226cff0eed41ffff2a71cfaa6d9214ba268 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Thu, 19 Mar 2026 09:28:03 -0400 Subject: [PATCH 01/10] feat: add --help support to break, continue, exit, echo, true, false - break, continue, exit: match bash builtin behaviour (stderr, exit code 2) - echo, true, false: match GNU coreutils behaviour (stdout, exit code 0) - Add scenario tests for all 6 commands - Update existing tests that expected --help to be ignored Co-Authored-By: Claude Opus 4.6 (1M context) --- builtins/break/break.go | 4 ++++ builtins/continue/continue.go | 4 ++++ builtins/echo/builtin_echo_pentest_test.go | 4 ++-- builtins/echo/echo.go | 4 ++++ builtins/echo/echo_test.go | 4 ++-- builtins/exit/exit.go | 4 ++++ builtins/false/false.go | 6 +++++- builtins/true/true.go | 5 ++++- tests/scenarios/cmd/echo/flags/help.yaml | 17 +++++++++++++++++ tests/scenarios/cmd/exit/help.yaml | 16 ++++++++++++++++ .../cmd/false/basic/with_unknown_flag.yaml | 4 +--- tests/scenarios/cmd/false/help.yaml | 11 +++++++++++ .../cmd/true/basic/with_unknown_flag.yaml | 4 +--- tests/scenarios/cmd/true/help.yaml | 11 +++++++++++ .../shell/for_clause/break_cont/break_help.yaml | 17 +++++++++++++++++ .../for_clause/break_cont/continue_help.yaml | 17 +++++++++++++++++ 16 files changed, 120 insertions(+), 12 deletions(-) create mode 100644 tests/scenarios/cmd/echo/flags/help.yaml create mode 100644 tests/scenarios/cmd/exit/help.yaml create mode 100644 tests/scenarios/cmd/false/help.yaml create mode 100644 tests/scenarios/cmd/true/help.yaml create mode 100644 tests/scenarios/shell/for_clause/break_cont/break_help.yaml create mode 100644 tests/scenarios/shell/for_clause/break_cont/continue_help.yaml diff --git a/builtins/break/break.go b/builtins/break/break.go index 22f13f3e..98723e75 100644 --- a/builtins/break/break.go +++ b/builtins/break/break.go @@ -29,5 +29,9 @@ import ( var Cmd = builtins.Command{Name: "break", Description: "exit from a loop", MakeFlags: builtins.NoFlags(run)} func run(_ context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { + if len(args) > 0 && args[0] == "--help" { + callCtx.Errf("break: break [n]\n Exit for, while, or until loops.\n\n Exit a FOR, WHILE or UNTIL loop. If N is specified, break N enclosing\n loops.\n\n Exit Status:\n The exit status is 0 unless N is not greater than or equal to 1.\n") + return builtins.Result{Code: 2} + } return loopctl.LoopControl(callCtx, "break", args) } diff --git a/builtins/continue/continue.go b/builtins/continue/continue.go index 7574431b..0d58e147 100644 --- a/builtins/continue/continue.go +++ b/builtins/continue/continue.go @@ -29,5 +29,9 @@ import ( var Cmd = builtins.Command{Name: "continue", Description: "continue a loop iteration", MakeFlags: builtins.NoFlags(run)} func run(_ context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { + if len(args) > 0 && args[0] == "--help" { + callCtx.Errf("continue: continue [n]\n Resume for, while, or until loops.\n\n Resumes the next iteration of the enclosing FOR, WHILE or UNTIL loop.\n If N is specified, resumes the Nth enclosing loop.\n\n Exit Status:\n The exit status is 0 unless N is not greater than or equal to 1.\n") + return builtins.Result{Code: 2} + } return loopctl.LoopControl(callCtx, "continue", args) } diff --git a/builtins/echo/builtin_echo_pentest_test.go b/builtins/echo/builtin_echo_pentest_test.go index 31bc4371..f2054634 100644 --- a/builtins/echo/builtin_echo_pentest_test.go +++ b/builtins/echo/builtin_echo_pentest_test.go @@ -43,10 +43,10 @@ func TestEchoPentestDoubleDashNotEndOfOptions(t *testing.T) { assert.Equal(t, "-- -n hello\n", stdout) } -func TestEchoPentestHelpNotInterpreted(t *testing.T) { +func TestEchoPentestHelpDisplaysUsage(t *testing.T) { stdout, _, code := runScript(t, "echo --help") assert.Equal(t, 0, code) - assert.Equal(t, "--help\n", stdout) + assert.Contains(t, stdout, "Usage: echo") } func TestEchoPentestVersionNotInterpreted(t *testing.T) { diff --git a/builtins/echo/echo.go b/builtins/echo/echo.go index 08108183..a1e58ddb 100644 --- a/builtins/echo/echo.go +++ b/builtins/echo/echo.go @@ -57,6 +57,10 @@ import ( var Cmd = builtins.Command{Name: "echo", Description: "write arguments to stdout", MakeFlags: builtins.NoFlags(run)} func run(_ context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { + if len(args) > 0 && args[0] == "--help" { + callCtx.Out("Usage: echo [-neE] [ARG]...\nWrite each ARG to standard output, separated by a single space,\nfollowed by a newline.\n\n -n do not output the trailing newline\n -e enable interpretation of backslash escapes\n -E disable interpretation of backslash escapes (default)\n --help display this help and exit\n") + return builtins.Result{} + } // Parse flags: bash treats leading args matching -[neE]+ as flags. // Once a non-matching arg is seen, everything from that point is text. var noNewline, escapes bool diff --git a/builtins/echo/echo_test.go b/builtins/echo/echo_test.go index 94ad7156..5c229952 100644 --- a/builtins/echo/echo_test.go +++ b/builtins/echo/echo_test.go @@ -240,10 +240,10 @@ func TestEchoFlagAfterTextIsLiteral(t *testing.T) { assert.Equal(t, "hello -n\n", stdout) } -func TestEchoHelpIsLiteral(t *testing.T) { +func TestEchoHelpDisplaysUsage(t *testing.T) { stdout, _, code := runScript(t, "echo --help") assert.Equal(t, 0, code) - assert.Equal(t, "--help\n", stdout) + assert.Contains(t, stdout, "Usage: echo") } func TestEchoVersionIsLiteral(t *testing.T) { diff --git a/builtins/exit/exit.go b/builtins/exit/exit.go index 9acbe923..573090cd 100644 --- a/builtins/exit/exit.go +++ b/builtins/exit/exit.go @@ -31,6 +31,10 @@ import ( var Cmd = builtins.Command{Name: "exit", Description: "exit the shell", MakeFlags: builtins.NoFlags(run)} func run(_ context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { + if len(args) > 0 && args[0] == "--help" { + callCtx.Errf("exit: exit [n]\n Exit the shell.\n\n Exits the shell with a status of N. If N is omitted, the exit status\n is that of the last command executed.\n") + return builtins.Result{Code: 2} + } var r builtins.Result if len(args) > 0 && args[0] == "--" { args = args[1:] diff --git a/builtins/false/false.go b/builtins/false/false.go index 08ba6004..6e7a97e9 100644 --- a/builtins/false/false.go +++ b/builtins/false/false.go @@ -25,6 +25,10 @@ import ( // Cmd is the false builtin command descriptor. var Cmd = builtins.Command{Name: "false", Description: "return unsuccessful exit status", MakeFlags: builtins.NoFlags(run)} -func run(_ context.Context, _ *builtins.CallContext, _ []string) builtins.Result { +func run(_ context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { + if len(args) > 0 && args[0] == "--help" { + callCtx.Out("Usage: false\nExit with a status code indicating failure.\n") + return builtins.Result{} + } return builtins.Result{Code: 1} } diff --git a/builtins/true/true.go b/builtins/true/true.go index 36c6155a..bdc8d992 100644 --- a/builtins/true/true.go +++ b/builtins/true/true.go @@ -25,6 +25,9 @@ import ( // Cmd is the true builtin command descriptor. var Cmd = builtins.Command{Name: "true", Description: "return successful exit status", MakeFlags: builtins.NoFlags(run)} -func run(_ context.Context, _ *builtins.CallContext, _ []string) builtins.Result { +func run(_ context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { + if len(args) > 0 && args[0] == "--help" { + callCtx.Out("Usage: true\nExit with a status code indicating success.\n") + } return builtins.Result{} } diff --git a/tests/scenarios/cmd/echo/flags/help.yaml b/tests/scenarios/cmd/echo/flags/help.yaml new file mode 100644 index 00000000..b3d4276c --- /dev/null +++ b/tests/scenarios/cmd/echo/flags/help.yaml @@ -0,0 +1,17 @@ +description: Echo --help displays usage information. +skip_assert_against_bash: true # bash builtin echo prints --help literally; we match GNU coreutils +input: + script: |+ + echo --help +expect: + stdout: |+ + Usage: echo [-neE] [ARG]... + Write each ARG to standard output, separated by a single space, + followed by a newline. + + -n do not output the trailing newline + -e enable interpretation of backslash escapes + -E disable interpretation of backslash escapes (default) + --help display this help and exit + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/exit/help.yaml b/tests/scenarios/cmd/exit/help.yaml new file mode 100644 index 00000000..8dda5822 --- /dev/null +++ b/tests/scenarios/cmd/exit/help.yaml @@ -0,0 +1,16 @@ +description: Exit --help displays usage information without exiting the shell. +skip_assert_against_bash: true # bash outputs slightly different formatting +input: + script: |+ + exit --help + echo "still running" +expect: + stdout: |+ + still running + stderr: |+ + exit: exit [n] + Exit the shell. + + Exits the shell with a status of N. If N is omitted, the exit status + is that of the last command executed. + exit_code: 0 diff --git a/tests/scenarios/cmd/false/basic/with_unknown_flag.yaml b/tests/scenarios/cmd/false/basic/with_unknown_flag.yaml index 7cb13b38..66abddb1 100644 --- a/tests/scenarios/cmd/false/basic/with_unknown_flag.yaml +++ b/tests/scenarios/cmd/false/basic/with_unknown_flag.yaml @@ -1,16 +1,14 @@ description: The false builtin ignores unknown flags and still exits 1. +skip_assert_against_bash: true # bash builtin false ignores --help; we match GNU coreutils input: script: |+ false -x echo $? - false --help - echo $? false --unknown-flag echo $? expect: stdout: |+ 1 1 - 1 stderr: "" exit_code: 0 diff --git a/tests/scenarios/cmd/false/help.yaml b/tests/scenarios/cmd/false/help.yaml new file mode 100644 index 00000000..3dbc737f --- /dev/null +++ b/tests/scenarios/cmd/false/help.yaml @@ -0,0 +1,11 @@ +description: False --help displays usage information. +skip_assert_against_bash: true # bash builtin false ignores --help; we match GNU coreutils +input: + script: |+ + false --help +expect: + stdout: |+ + Usage: false + Exit with a status code indicating failure. + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/cmd/true/basic/with_unknown_flag.yaml b/tests/scenarios/cmd/true/basic/with_unknown_flag.yaml index dd385766..0fec98de 100644 --- a/tests/scenarios/cmd/true/basic/with_unknown_flag.yaml +++ b/tests/scenarios/cmd/true/basic/with_unknown_flag.yaml @@ -1,16 +1,14 @@ description: The true builtin ignores unknown flags and still exits 0. +skip_assert_against_bash: true # bash builtin true ignores --help; we match GNU coreutils input: script: |+ true -x echo $? - true --help - echo $? true --unknown-flag echo $? expect: stdout: |+ 0 0 - 0 stderr: "" exit_code: 0 diff --git a/tests/scenarios/cmd/true/help.yaml b/tests/scenarios/cmd/true/help.yaml new file mode 100644 index 00000000..922b4e12 --- /dev/null +++ b/tests/scenarios/cmd/true/help.yaml @@ -0,0 +1,11 @@ +description: True --help displays usage information. +skip_assert_against_bash: true # bash builtin true ignores --help; we match GNU coreutils +input: + script: |+ + true --help +expect: + stdout: |+ + Usage: true + Exit with a status code indicating success. + stderr: "" + exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/break_cont/break_help.yaml b/tests/scenarios/shell/for_clause/break_cont/break_help.yaml new file mode 100644 index 00000000..5ca2da8e --- /dev/null +++ b/tests/scenarios/shell/for_clause/break_cont/break_help.yaml @@ -0,0 +1,17 @@ +description: Break --help displays usage information. +skip_assert_against_bash: true # bash outputs slightly different formatting +input: + script: |+ + for i in 1; do break --help; done +expect: + stdout: "" + stderr: |+ + break: break [n] + Exit for, while, or until loops. + + Exit a FOR, WHILE or UNTIL loop. If N is specified, break N enclosing + loops. + + Exit Status: + The exit status is 0 unless N is not greater than or equal to 1. + exit_code: 2 diff --git a/tests/scenarios/shell/for_clause/break_cont/continue_help.yaml b/tests/scenarios/shell/for_clause/break_cont/continue_help.yaml new file mode 100644 index 00000000..0278455c --- /dev/null +++ b/tests/scenarios/shell/for_clause/break_cont/continue_help.yaml @@ -0,0 +1,17 @@ +description: Continue --help displays usage information. +skip_assert_against_bash: true # bash outputs slightly different formatting +input: + script: |+ + for i in 1; do continue --help; done +expect: + stdout: "" + stderr: |+ + continue: continue [n] + Resume for, while, or until loops. + + Resumes the next iteration of the enclosing FOR, WHILE or UNTIL loop. + If N is specified, resumes the Nth enclosing loop. + + Exit Status: + The exit status is 0 unless N is not greater than or equal to 1. + exit_code: 2 From 2db04a0146e20c7ce8c9009d4e3df8da3f930bc7 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Thu, 19 Mar 2026 09:44:27 -0400 Subject: [PATCH 02/10] fix: preserve non-zero exit status for false --help false is a guaranteed failure primitive and should always exit non-zero, even when displaying help text. Co-Authored-By: Claude Opus 4.6 (1M context) --- builtins/false/false.go | 2 +- tests/scenarios/cmd/false/help.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/builtins/false/false.go b/builtins/false/false.go index 6e7a97e9..f584ce76 100644 --- a/builtins/false/false.go +++ b/builtins/false/false.go @@ -28,7 +28,7 @@ var Cmd = builtins.Command{Name: "false", Description: "return unsuccessful exit func run(_ context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { if len(args) > 0 && args[0] == "--help" { callCtx.Out("Usage: false\nExit with a status code indicating failure.\n") - return builtins.Result{} + return builtins.Result{Code: 1} } return builtins.Result{Code: 1} } diff --git a/tests/scenarios/cmd/false/help.yaml b/tests/scenarios/cmd/false/help.yaml index 3dbc737f..69531943 100644 --- a/tests/scenarios/cmd/false/help.yaml +++ b/tests/scenarios/cmd/false/help.yaml @@ -8,4 +8,4 @@ expect: Usage: false Exit with a status code indicating failure. stderr: "" - exit_code: 0 + exit_code: 1 From ee311f53f511604059db7449007e5d6f586f74e1 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Thu, 19 Mar 2026 09:57:17 -0400 Subject: [PATCH 03/10] revert: keep echo --help as literal text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit echo is a shell builtin — bash's builtin echo treats --help as literal text, not a flag. Reverts to bash-compatible behaviour. Co-Authored-By: Claude Opus 4.6 (1M context) --- builtins/echo/builtin_echo_pentest_test.go | 4 ++-- builtins/echo/echo.go | 4 ---- builtins/echo/echo_test.go | 4 ++-- tests/scenarios/cmd/echo/flags/help.yaml | 17 ----------------- 4 files changed, 4 insertions(+), 25 deletions(-) delete mode 100644 tests/scenarios/cmd/echo/flags/help.yaml diff --git a/builtins/echo/builtin_echo_pentest_test.go b/builtins/echo/builtin_echo_pentest_test.go index f2054634..31bc4371 100644 --- a/builtins/echo/builtin_echo_pentest_test.go +++ b/builtins/echo/builtin_echo_pentest_test.go @@ -43,10 +43,10 @@ func TestEchoPentestDoubleDashNotEndOfOptions(t *testing.T) { assert.Equal(t, "-- -n hello\n", stdout) } -func TestEchoPentestHelpDisplaysUsage(t *testing.T) { +func TestEchoPentestHelpNotInterpreted(t *testing.T) { stdout, _, code := runScript(t, "echo --help") assert.Equal(t, 0, code) - assert.Contains(t, stdout, "Usage: echo") + assert.Equal(t, "--help\n", stdout) } func TestEchoPentestVersionNotInterpreted(t *testing.T) { diff --git a/builtins/echo/echo.go b/builtins/echo/echo.go index a1e58ddb..08108183 100644 --- a/builtins/echo/echo.go +++ b/builtins/echo/echo.go @@ -57,10 +57,6 @@ import ( var Cmd = builtins.Command{Name: "echo", Description: "write arguments to stdout", MakeFlags: builtins.NoFlags(run)} func run(_ context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { - if len(args) > 0 && args[0] == "--help" { - callCtx.Out("Usage: echo [-neE] [ARG]...\nWrite each ARG to standard output, separated by a single space,\nfollowed by a newline.\n\n -n do not output the trailing newline\n -e enable interpretation of backslash escapes\n -E disable interpretation of backslash escapes (default)\n --help display this help and exit\n") - return builtins.Result{} - } // Parse flags: bash treats leading args matching -[neE]+ as flags. // Once a non-matching arg is seen, everything from that point is text. var noNewline, escapes bool diff --git a/builtins/echo/echo_test.go b/builtins/echo/echo_test.go index 5c229952..94ad7156 100644 --- a/builtins/echo/echo_test.go +++ b/builtins/echo/echo_test.go @@ -240,10 +240,10 @@ func TestEchoFlagAfterTextIsLiteral(t *testing.T) { assert.Equal(t, "hello -n\n", stdout) } -func TestEchoHelpDisplaysUsage(t *testing.T) { +func TestEchoHelpIsLiteral(t *testing.T) { stdout, _, code := runScript(t, "echo --help") assert.Equal(t, 0, code) - assert.Contains(t, stdout, "Usage: echo") + assert.Equal(t, "--help\n", stdout) } func TestEchoVersionIsLiteral(t *testing.T) { diff --git a/tests/scenarios/cmd/echo/flags/help.yaml b/tests/scenarios/cmd/echo/flags/help.yaml deleted file mode 100644 index b3d4276c..00000000 --- a/tests/scenarios/cmd/echo/flags/help.yaml +++ /dev/null @@ -1,17 +0,0 @@ -description: Echo --help displays usage information. -skip_assert_against_bash: true # bash builtin echo prints --help literally; we match GNU coreutils -input: - script: |+ - echo --help -expect: - stdout: |+ - Usage: echo [-neE] [ARG]... - Write each ARG to standard output, separated by a single space, - followed by a newline. - - -n do not output the trailing newline - -e enable interpretation of backslash escapes - -E disable interpretation of backslash escapes (default) - --help display this help and exit - stderr: "" - exit_code: 0 From a1a49073cdd05b0777f7b5e63508000b46a7c513 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Thu, 19 Mar 2026 09:59:42 -0400 Subject: [PATCH 04/10] revert: keep true and false silent on --help MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit true and false are shell builtins — bash ignores all arguments silently. Reverts to bash-compatible behaviour where --help is a no-op. Co-Authored-By: Claude Opus 4.6 (1M context) --- builtins/false/false.go | 6 +----- builtins/true/true.go | 5 +---- .../scenarios/cmd/false/basic/with_unknown_flag.yaml | 4 +++- tests/scenarios/cmd/false/help.yaml | 11 ----------- tests/scenarios/cmd/true/basic/with_unknown_flag.yaml | 4 +++- tests/scenarios/cmd/true/help.yaml | 11 ----------- 6 files changed, 8 insertions(+), 33 deletions(-) delete mode 100644 tests/scenarios/cmd/false/help.yaml delete mode 100644 tests/scenarios/cmd/true/help.yaml diff --git a/builtins/false/false.go b/builtins/false/false.go index f584ce76..08ba6004 100644 --- a/builtins/false/false.go +++ b/builtins/false/false.go @@ -25,10 +25,6 @@ import ( // Cmd is the false builtin command descriptor. var Cmd = builtins.Command{Name: "false", Description: "return unsuccessful exit status", MakeFlags: builtins.NoFlags(run)} -func run(_ context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { - if len(args) > 0 && args[0] == "--help" { - callCtx.Out("Usage: false\nExit with a status code indicating failure.\n") - return builtins.Result{Code: 1} - } +func run(_ context.Context, _ *builtins.CallContext, _ []string) builtins.Result { return builtins.Result{Code: 1} } diff --git a/builtins/true/true.go b/builtins/true/true.go index bdc8d992..36c6155a 100644 --- a/builtins/true/true.go +++ b/builtins/true/true.go @@ -25,9 +25,6 @@ import ( // Cmd is the true builtin command descriptor. var Cmd = builtins.Command{Name: "true", Description: "return successful exit status", MakeFlags: builtins.NoFlags(run)} -func run(_ context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { - if len(args) > 0 && args[0] == "--help" { - callCtx.Out("Usage: true\nExit with a status code indicating success.\n") - } +func run(_ context.Context, _ *builtins.CallContext, _ []string) builtins.Result { return builtins.Result{} } diff --git a/tests/scenarios/cmd/false/basic/with_unknown_flag.yaml b/tests/scenarios/cmd/false/basic/with_unknown_flag.yaml index 66abddb1..7cb13b38 100644 --- a/tests/scenarios/cmd/false/basic/with_unknown_flag.yaml +++ b/tests/scenarios/cmd/false/basic/with_unknown_flag.yaml @@ -1,14 +1,16 @@ description: The false builtin ignores unknown flags and still exits 1. -skip_assert_against_bash: true # bash builtin false ignores --help; we match GNU coreutils input: script: |+ false -x echo $? + false --help + echo $? false --unknown-flag echo $? expect: stdout: |+ 1 1 + 1 stderr: "" exit_code: 0 diff --git a/tests/scenarios/cmd/false/help.yaml b/tests/scenarios/cmd/false/help.yaml deleted file mode 100644 index 69531943..00000000 --- a/tests/scenarios/cmd/false/help.yaml +++ /dev/null @@ -1,11 +0,0 @@ -description: False --help displays usage information. -skip_assert_against_bash: true # bash builtin false ignores --help; we match GNU coreutils -input: - script: |+ - false --help -expect: - stdout: |+ - Usage: false - Exit with a status code indicating failure. - stderr: "" - exit_code: 1 diff --git a/tests/scenarios/cmd/true/basic/with_unknown_flag.yaml b/tests/scenarios/cmd/true/basic/with_unknown_flag.yaml index 0fec98de..dd385766 100644 --- a/tests/scenarios/cmd/true/basic/with_unknown_flag.yaml +++ b/tests/scenarios/cmd/true/basic/with_unknown_flag.yaml @@ -1,14 +1,16 @@ description: The true builtin ignores unknown flags and still exits 0. -skip_assert_against_bash: true # bash builtin true ignores --help; we match GNU coreutils input: script: |+ true -x echo $? + true --help + echo $? true --unknown-flag echo $? expect: stdout: |+ 0 0 + 0 stderr: "" exit_code: 0 diff --git a/tests/scenarios/cmd/true/help.yaml b/tests/scenarios/cmd/true/help.yaml deleted file mode 100644 index 922b4e12..00000000 --- a/tests/scenarios/cmd/true/help.yaml +++ /dev/null @@ -1,11 +0,0 @@ -description: True --help displays usage information. -skip_assert_against_bash: true # bash builtin true ignores --help; we match GNU coreutils -input: - script: |+ - true --help -expect: - stdout: |+ - Usage: true - Exit with a status code indicating success. - stderr: "" - exit_code: 0 From 95f73781dc603e22b1c331861172b8c0ad35753c Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Thu, 19 Mar 2026 10:14:30 -0400 Subject: [PATCH 05/10] feat(help): extend help builtin to support help Add per-command help text to all 26 builtins via a new Help field on the Command struct. Running 'help ' now displays detailed usage for that command, matching the output of ' --help' where supported. This is especially useful for builtins like echo, true, and false that don't support --help (matching bash builtin behaviour). Co-Authored-By: Claude Opus 4.6 (1M context) --- builtins/break/break.go | 14 ++- builtins/builtins.go | 4 +- builtins/cat/cat.go | 21 +++- builtins/continue/continue.go | 14 ++- builtins/cut/cut.go | 19 ++- builtins/echo/echo.go | 19 ++- builtins/exit/exit.go | 11 +- builtins/false/false.go | 10 +- builtins/find/find.go | 49 +++++++- builtins/grep/grep.go | 34 ++++- builtins/head/head.go | 16 ++- builtins/help/help.go | 53 ++++++-- builtins/ip/ip.go | 22 +++- builtins/ls/ls.go | 25 +++- builtins/ping/ping.go | 16 ++- builtins/printf/printf.go | 7 +- builtins/ps/ps.go | 14 ++- builtins/sed/sed.go | 15 ++- builtins/sort/sort.go | 21 +++- builtins/ss/ss.go | 22 +++- builtins/strings_cmd/strings.go | 17 ++- builtins/tail/tail.go | 17 ++- builtins/testcmd/testcmd.go | 116 +++++++++++++++++- builtins/tests/help/help_test.go | 22 ++-- builtins/tr/tr.go | 15 ++- builtins/true/true.go | 10 +- builtins/uniq/uniq.go | 21 +++- builtins/wc/wc.go | 16 ++- .../cmd/help/restricted_commands.yaml | 4 +- tests/scenarios/cmd/help/too_many_args.yaml | 6 +- 30 files changed, 595 insertions(+), 55 deletions(-) diff --git a/builtins/break/break.go b/builtins/break/break.go index 98723e75..31fe886b 100644 --- a/builtins/break/break.go +++ b/builtins/break/break.go @@ -26,7 +26,19 @@ import ( ) // Cmd is the break builtin command descriptor. -var Cmd = builtins.Command{Name: "break", Description: "exit from a loop", MakeFlags: builtins.NoFlags(run)} +var Cmd = builtins.Command{ + Name: "break", + Description: "exit from a loop", + Help: `break: break [n] + Exit for, while, or until loops. + + Exit a FOR, WHILE or UNTIL loop. If N is specified, break N enclosing + loops. + + Exit Status: + The exit status is 0 unless N is not greater than or equal to 1.`, + MakeFlags: builtins.NoFlags(run), +} func run(_ context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { if len(args) > 0 && args[0] == "--help" { diff --git a/builtins/builtins.go b/builtins/builtins.go index 586d0ff5..4106e151 100644 --- a/builtins/builtins.go +++ b/builtins/builtins.go @@ -36,6 +36,7 @@ type HandlerFunc func(ctx context.Context, callCtx *CallContext, args []string) type Command struct { Name string Description string + Help string MakeFlags func(*FlagSet) HandlerFunc } @@ -56,7 +57,7 @@ func NoFlags(fn HandlerFunc) func(*FlagSet) HandlerFunc { func (c Command) Register() { name := c.Name factory := c.MakeFlags - metaRegistry[name] = CommandMeta{Name: name, Description: c.Description} + metaRegistry[name] = CommandMeta{Name: name, Description: c.Description, Help: c.Help} addToRegistry(name, func(ctx context.Context, callCtx *CallContext, args []string) Result { fs := pflag.NewFlagSet(name, pflag.ContinueOnError) fs.SetOutput(io.Discard) // handler formats errors itself @@ -198,6 +199,7 @@ var registry = map[string]HandlerFunc{} type CommandMeta struct { Name string Description string + Help string } var metaRegistry = map[string]CommandMeta{} diff --git a/builtins/cat/cat.go b/builtins/cat/cat.go index 91d74c85..3f78b37b 100644 --- a/builtins/cat/cat.go +++ b/builtins/cat/cat.go @@ -76,7 +76,26 @@ import ( ) // Cmd is the cat builtin command descriptor. -var Cmd = builtins.Command{Name: "cat", Description: "concatenate and print files", MakeFlags: registerFlags} +var Cmd = builtins.Command{ + Name: "cat", + Description: "concatenate and print files", + Help: `Usage: cat [OPTION]... [FILE]... +Concatenate FILE(s) to standard output. +With no FILE, or when FILE is -, read standard input. + + --help print usage and exit + -n, --number number all output lines + -b, --number-nonblank number non-blank output lines, overrides -n + -A, --show-all equivalent to -vET + -E, --show-ends display $ at end of each line + -v, --show-nonprinting use ^ and M- notation, except for LFD and TAB + -e, --show-nonprinting-ends equivalent to -vE + -t, --show-nonprinting-tabs equivalent to -vT + -T, --show-tabs display TAB characters as ^I + -s, --squeeze-blank suppress repeated empty output lines + -u, --unbuffered ignored`, + MakeFlags: registerFlags, +} // MaxLineBytes is the per-line buffer cap for the line scanner. Lines // longer than this are reported as an error instead of being buffered. diff --git a/builtins/continue/continue.go b/builtins/continue/continue.go index 0d58e147..edd33d84 100644 --- a/builtins/continue/continue.go +++ b/builtins/continue/continue.go @@ -26,7 +26,19 @@ import ( ) // Cmd is the continue builtin command descriptor. -var Cmd = builtins.Command{Name: "continue", Description: "continue a loop iteration", MakeFlags: builtins.NoFlags(run)} +var Cmd = builtins.Command{ + Name: "continue", + Description: "continue a loop iteration", + Help: `continue: continue [n] + Resume for, while, or until loops. + + Resumes the next iteration of the enclosing FOR, WHILE or UNTIL loop. + If N is specified, resumes the Nth enclosing loop. + + Exit Status: + The exit status is 0 unless N is not greater than or equal to 1.`, + MakeFlags: builtins.NoFlags(run), +} func run(_ context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { if len(args) > 0 && args[0] == "--help" { diff --git a/builtins/cut/cut.go b/builtins/cut/cut.go index c195e236..0b3b4237 100644 --- a/builtins/cut/cut.go +++ b/builtins/cut/cut.go @@ -75,7 +75,24 @@ import ( ) // Cmd is the cut builtin command descriptor. -var Cmd = builtins.Command{Name: "cut", Description: "remove sections from each line", MakeFlags: registerFlags} +var Cmd = builtins.Command{ + Name: "cut", + Description: "remove sections from each line", + Help: `Usage: cut OPTION... [FILE]... +Print selected parts of lines from each FILE to standard output. +With no FILE, or when FILE is -, read standard input. + + -n, -- do not split multi-byte characters + -b, --bytes string select only these bytes + -c, --characters string select only these characters + --complement complement the set of selected bytes, characters, or fields + -d, --delimiter string use DELIM instead of TAB for field delimiter + -f, --fields string select only these fields + --help print usage and exit + -s, --only-delimited do not print lines not containing delimiters + --output-delimiter string use STRING as the output delimiter`, + MakeFlags: registerFlags, +} // MaxLineBytes is the per-line buffer cap for the line scanner. const MaxLineBytes = 1 << 20 // 1 MiB diff --git a/builtins/echo/echo.go b/builtins/echo/echo.go index 08108183..f4c6766f 100644 --- a/builtins/echo/echo.go +++ b/builtins/echo/echo.go @@ -54,7 +54,24 @@ import ( ) // Cmd is the echo builtin command descriptor. -var Cmd = builtins.Command{Name: "echo", Description: "write arguments to stdout", MakeFlags: builtins.NoFlags(run)} +var Cmd = builtins.Command{ + Name: "echo", + Description: "write arguments to stdout", + Help: `echo: echo [-neE] [arg ...] + Write arguments to the standard output. + + Display the ARGs, separated by a single space character and followed by a + newline, on the standard output. + + Options: + -n do not output the trailing newline + -e enable interpretation of backslash escapes + -E disable interpretation of backslash escapes (default) + + Exit Status: + Returns success unless a write error occurs.`, + MakeFlags: builtins.NoFlags(run), +} func run(_ context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { // Parse flags: bash treats leading args matching -[neE]+ as flags. diff --git a/builtins/exit/exit.go b/builtins/exit/exit.go index 573090cd..496342dc 100644 --- a/builtins/exit/exit.go +++ b/builtins/exit/exit.go @@ -28,7 +28,16 @@ import ( ) // Cmd is the exit builtin command descriptor. -var Cmd = builtins.Command{Name: "exit", Description: "exit the shell", MakeFlags: builtins.NoFlags(run)} +var Cmd = builtins.Command{ + Name: "exit", + Description: "exit the shell", + Help: `exit: exit [n] + Exit the shell. + + Exits the shell with a status of N. If N is omitted, the exit status + is that of the last command executed.`, + MakeFlags: builtins.NoFlags(run), +} func run(_ context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { if len(args) > 0 && args[0] == "--help" { diff --git a/builtins/false/false.go b/builtins/false/false.go index 08ba6004..52779fea 100644 --- a/builtins/false/false.go +++ b/builtins/false/false.go @@ -23,7 +23,15 @@ import ( ) // Cmd is the false builtin command descriptor. -var Cmd = builtins.Command{Name: "false", Description: "return unsuccessful exit status", MakeFlags: builtins.NoFlags(run)} +var Cmd = builtins.Command{ + Name: "false", + Description: "return unsuccessful exit status", + Help: `false: false + Return an unsuccessful result. + + Exit with a status code indicating failure.`, + MakeFlags: builtins.NoFlags(run), +} func run(_ context.Context, _ *builtins.CallContext, _ []string) builtins.Result { return builtins.Result{Code: 1} diff --git a/builtins/find/find.go b/builtins/find/find.go index 1e48eaf1..f886c30e 100644 --- a/builtins/find/find.go +++ b/builtins/find/find.go @@ -97,7 +97,54 @@ func isNotExist(err error) bool { const maxTraversalDepth = 256 // Cmd is the find builtin command descriptor. -var Cmd = builtins.Command{Name: "find", Description: "search for files in a directory hierarchy", MakeFlags: builtins.NoFlags(run)} +var Cmd = builtins.Command{ + Name: "find", + Description: "search for files in a directory hierarchy", + Help: `Usage: find [-L] [-P] [path...] [expression] + +Search directory trees, evaluating an expression for each file found. +Default path is the current directory; default expression is -print. + +Options: + --help Print this help and exit. + -L Follow symbolic links. + -P Never follow symbolic links (default). + +Tests: + -name PATTERN Base name matches shell glob PATTERN. + -iname PATTERN Like -name but case-insensitive. + -path PATTERN Full path matches shell glob PATTERN. + -ipath PATTERN Like -path but case-insensitive. + -type TYPE File type: b,c,d,f,l,p,s. Comma-separated for OR. + -size N[cwbkMG] File size (+N=greater, -N=less, N=exact). + -empty Empty regular file or directory. + -newer FILE Modified more recently than FILE. + -mtime N Modified N days ago (+N=more, -N=less). + -mmin N Modified N minutes ago (+N=more, -N=less). + -perm MODE Permission bits match MODE (octal or symbolic). + -maxdepth N Descend at most N levels. + -mindepth N Apply tests only at depth >= N. + -true Always true. + -false Always false. + +Actions: + -print Print path followed by newline. + -print0 Print path followed by NUL. + -prune Skip directory subtree. + -quit Exit immediately. + +Operators: + ( EXPR ) Grouping. + ! EXPR / -not EXPR Negation. + EXPR -a EXPR / EXPR -and EXPR Conjunction (implicit). + EXPR -o EXPR / EXPR -or EXPR Disjunction. + +Blocked predicates [sandbox]: + -exec, -execdir, -delete, -ok, -okdir Execution/deletion. + -fls, -fprint, -fprint0, -fprintf File writes. + -regex, -iregex ReDoS risk.`, + MakeFlags: builtins.NoFlags(run), +} func run(ctx context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { // Parse global options (-L) and separate paths from expression. diff --git a/builtins/grep/grep.go b/builtins/grep/grep.go index 59ec59aa..0d43ff83 100644 --- a/builtins/grep/grep.go +++ b/builtins/grep/grep.go @@ -125,7 +125,39 @@ import ( ) // Cmd is the grep builtin command descriptor. -var Cmd = builtins.Command{Name: "grep", Description: "print lines that match patterns", MakeFlags: registerFlags} +var Cmd = builtins.Command{ + Name: "grep", + Description: "print lines that match patterns", + Help: `Usage: grep [OPTION]... PATTERN [FILE]... +Search for PATTERN in each FILE. +When FILE is -, read standard input. With no FILE, read standard input. + + -A, --after-context int print NUM lines after each match + -G, --basic-regexp use basic regular expressions (default) + -B, --before-context int print NUM lines before each match + -C, --context int print NUM lines of context around each match + -c, --count print only a count of matching lines per file + -E, --extended-regexp use extended regular expressions + -l, --files-with-matches print only names of files with matches + -L, --files-without-match print only names of files without matches + -F, --fixed-strings interpret pattern as fixed strings + --help print usage and exit + -i, --ignore-case ignore case distinctions + -v, --invert-match select non-matching lines + -n, --line-number prefix output with line numbers + -x, --line-regexp match only whole lines + -m, --max-count int stop after NUM matches per file + -h, --no-filename suppress filename prefix + -s, --no-messages suppress error messages + -o, --only-matching print only the matched parts + -q, --quiet suppress all output + -e, --regexp string use PATTERN as the pattern + --silent alias for --quiet + -a, --text process binary file as if it were text + -H, --with-filename always print filename prefix + -w, --word-regexp match only whole words`, + MakeFlags: registerFlags, +} // MaxLineBytes is the per-line buffer cap for the line scanner. Lines // longer than this are reported as an error instead of being buffered. diff --git a/builtins/head/head.go b/builtins/head/head.go index 177494ee..317f9779 100644 --- a/builtins/head/head.go +++ b/builtins/head/head.go @@ -60,7 +60,21 @@ import ( ) // Cmd is the head builtin command descriptor. -var Cmd = builtins.Command{Name: "head", Description: "output the first part of files", MakeFlags: registerFlags} +var Cmd = builtins.Command{ + Name: "head", + Description: "output the first part of files", + Help: `Usage: head [OPTION]... [FILE]... +Print the first 10 lines of each FILE to standard output. +With no FILE, or when FILE is -, read standard input. + + -c, --bytes string print the first N bytes instead of lines + -h, --help print usage and exit + -n, --lines string print the first N lines instead of the first 10 + -q, --quiet never print file name headers + --silent alias for --quiet + -v, --verbose always print file name headers`, + MakeFlags: registerFlags, +} // MaxCount is the maximum accepted line or byte count. Values above this // are clamped. This prevents huge theoretical allocations while remaining diff --git a/builtins/help/help.go b/builtins/help/help.go index c9f5492d..18cedc55 100644 --- a/builtins/help/help.go +++ b/builtins/help/help.go @@ -5,17 +5,18 @@ // Package help implements the help builtin command. // -// help — display available commands +// help — display help for commands // -// Usage: help +// Usage: help [command] // -// List all available builtin commands with a brief description. -// For detailed information on a specific command, run ' --help'. +// With no arguments, list all available builtin commands with a brief +// description. When a command name is given, display detailed help for +// that command. // // Exit codes: // // 0 Success. -// 1 Arguments were provided or --help was requested. +// 1 Unknown command or --help was requested. package help import ( @@ -25,24 +26,52 @@ import ( ) // Cmd is the help builtin command descriptor. -var Cmd = builtins.Command{Name: "help", Description: "display available commands", MakeFlags: registerFlags} +var Cmd = builtins.Command{ + Name: "help", + Description: "display help for commands", + Help: `Usage: help [command] +Display help for builtin commands. + +With no arguments, list all available commands with a brief description. +When COMMAND is given, display detailed help for that command.`, + MakeFlags: registerFlags, +} func printUsage(callCtx *builtins.CallContext) { - callCtx.Out("Usage: help\n") - callCtx.Out("List all available builtin commands with a brief description.\n") - callCtx.Out("Takes no arguments.\n") + callCtx.Out("Usage: help [command]\n") + callCtx.Out("Display help for builtin commands.\n") } func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { helpFlag := fs.Bool("help", false, "print usage and exit") return func(_ context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { - if *helpFlag || len(args) > 0 { + if *helpFlag { printUsage(callCtx) return builtins.Result{Code: 1} } - // Filter to only commands allowed under the current policy. + // help — show detailed help for a specific command. + if len(args) > 0 { + name := args[0] + if callCtx.CommandAllowed != nil && !callCtx.CommandAllowed(name) { + callCtx.Errf("help: no help topics match '%s'\n", name) + return builtins.Result{Code: 1} + } + meta, ok := builtins.Meta(name) + if !ok { + callCtx.Errf("help: no help topics match '%s'\n", name) + return builtins.Result{Code: 1} + } + if meta.Help != "" { + callCtx.Outf("%s\n", meta.Help) + } else { + callCtx.Outf("%s - %s\n", meta.Name, meta.Description) + } + return builtins.Result{} + } + + // No arguments — list all allowed commands. allNames := builtins.Names() var names []string for _, name := range allNames { @@ -65,7 +94,7 @@ func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { callCtx.Outf("%-*s %s\n", maxLen, name, meta.Description) } - callCtx.Out("\nRun ' --help' for more information on a specific command.\n") + callCtx.Out("\nRun 'help ' for more information on a specific command.\n") return builtins.Result{} } } diff --git a/builtins/ip/ip.go b/builtins/ip/ip.go index 31815f05..af8b1f08 100644 --- a/builtins/ip/ip.go +++ b/builtins/ip/ip.go @@ -88,7 +88,27 @@ import ( ) // Cmd is the ip builtin command descriptor. -var Cmd = builtins.Command{Name: "ip", MakeFlags: registerFlags} +var Cmd = builtins.Command{ + Name: "ip", + Description: "show network interface information", + Help: `Usage: ip [GLOBAL-OPTIONS] OBJECT [COMMAND [ARGUMENTS]] +Show network interface information. + +Supported objects: + addr [show] [dev IFNAME] Show IP addresses + link [show] [dev IFNAME] Show link-layer information + +Global options: + --brief print brief information in tabular format + -h, --help print usage and exit + -4, --ipv4 show only IPv4 addresses + -6, --ipv6 show only IPv6 addresses + -o, --oneline output each record on a single line + +Note: -b/-B/-batch, -force, -n/--netns, and 'ip netns' are blocked for safety. +Note: the real ip command's -br flag is --brief in this builtin.`, + MakeFlags: registerFlags, +} // displayOpts holds the resolved global display options. type displayOpts struct { diff --git a/builtins/ls/ls.go b/builtins/ls/ls.go index 27c8509f..6edd250e 100644 --- a/builtins/ls/ls.go +++ b/builtins/ls/ls.go @@ -99,7 +99,30 @@ const MaxDirEntries = 1_000 var errFailed = errors.New("ls: one or more errors occurred") // Cmd is the ls builtin command descriptor. -var Cmd = builtins.Command{Name: "ls", Description: "list directory contents", MakeFlags: registerFlags} +var Cmd = builtins.Command{ + Name: "ls", + Description: "list directory contents", + Help: `Usage: ls [OPTION]... [FILE]... +List directory contents. +List information about the FILEs (the current directory by default). + + -a, --all do not ignore entries starting with . + -A, --almost-all do not ignore . and .. + -d, --directory list directories themselves, not their contents + -r, --reverse reverse order while sorting + -S, --sort-size sort by file size, largest first + -t, --sort-time sort by modification time, newest first + -F, --classify append indicator to entries + -p, --append-slash append / indicator to directories + -R, --recursive list subdirectories recursively + -l, --long use a long listing format + -h, --human-readable with -l, print human-readable sizes + -1, --one list one file per line + --offset int skip first N entries (pagination) + --limit int show at most N entries (capped at MaxDirEntries) + --help print usage and exit`, + MakeFlags: registerFlags, +} func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { // Preserve parse order so Visit() returns flags in the order set. diff --git a/builtins/ping/ping.go b/builtins/ping/ping.go index 122b54fc..f2d24c48 100644 --- a/builtins/ping/ping.go +++ b/builtins/ping/ping.go @@ -129,7 +129,21 @@ const ( var Cmd = builtins.Command{ Name: "ping", Description: "send ICMP echo requests to a network host", - MakeFlags: registerFlags, + Help: `Usage: ping [OPTION]... HOST +Send ICMP echo requests to HOST and report statistics. + +Options: + -c, --count int number of ICMP packets to send (1-20) + -h, --help print usage and exit 0 + -i, --interval string interval between packets (200ms-1m0s) + -4, --ipv4 use IPv4 + -6, --ipv6 use IPv6 + -q, --quiet quiet output: suppress per-packet lines + -W, --wait string time to wait for each reply (100ms-30s) + +Note: the following flags are not supported for safety and will be rejected: + -f (flood), -b (broadcast), -s (packet size), -I (interface), -p (pattern), -R (record route)`, + MakeFlags: registerFlags, } func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { diff --git a/builtins/printf/printf.go b/builtins/printf/printf.go index 7f6043da..82bc00f9 100644 --- a/builtins/printf/printf.go +++ b/builtins/printf/printf.go @@ -118,7 +118,12 @@ func isRangeErr(err error) bool { // Cmd is the printf builtin command descriptor. // printf uses NoFlags because its arguments (format string and data) can look // like flags (e.g. printf "%d" -42). Manual pre-parsing handles --help and -v. -var Cmd = builtins.Command{Name: "printf", Description: "format and print data", MakeFlags: builtins.NoFlags(run)} +var Cmd = builtins.Command{ + Name: "printf", + Description: "format and print data", + Help: `printf: usage: printf [-v var] format [arguments]`, + MakeFlags: builtins.NoFlags(run), +} // maxFormatIterations bounds the format-reuse loop to prevent runaway output. const maxFormatIterations = 10_000 diff --git a/builtins/ps/ps.go b/builtins/ps/ps.go index 169ebfe1..787ca4fe 100644 --- a/builtins/ps/ps.go +++ b/builtins/ps/ps.go @@ -51,7 +51,19 @@ import ( ) // Cmd is the ps builtin command descriptor. -var Cmd = builtins.Command{Name: "ps", Description: "report process status", MakeFlags: registerFlags} +var Cmd = builtins.Command{ + Name: "ps", + Description: "report process status", + Help: `Usage: ps [-e|-A] [-f] [-p PIDLIST] +Report process status. + + -A, --All select all processes (same as -e) + -e, --all select all processes + -f, --full full-format listing + --help print usage and exit + -p, --pid string select by PID list (comma or space separated)`, + MakeFlags: registerFlags, +} func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { // Both -e/--all and -A/--All write to the same bool so that diff --git a/builtins/sed/sed.go b/builtins/sed/sed.go index 6c83ae18..46184b44 100644 --- a/builtins/sed/sed.go +++ b/builtins/sed/sed.go @@ -116,7 +116,20 @@ import ( ) // Cmd is the sed builtin command descriptor. -var Cmd = builtins.Command{Name: "sed", Description: "stream editor for filtering and transforming text", MakeFlags: registerFlags} +var Cmd = builtins.Command{ + Name: "sed", + Description: "stream editor for filtering and transforming text", + Help: `Usage: sed [OPTION]... [script] [FILE]... +Stream editor for filtering and transforming text. +With no FILE, or when FILE is -, read standard input. + + -e, --expression string add script commands + -h, --help print usage and exit + -n, --quiet suppress automatic printing of pattern space + -E, --regexp-extended use extended regular expressions + --silent alias for --quiet`, + MakeFlags: registerFlags, +} // MaxLineBytes is the per-line buffer cap for the line scanner. const MaxLineBytes = 1 << 20 // 1 MiB diff --git a/builtins/sort/sort.go b/builtins/sort/sort.go index 02790d5c..a07f326e 100644 --- a/builtins/sort/sort.go +++ b/builtins/sort/sort.go @@ -84,7 +84,26 @@ import ( ) // Cmd is the sort builtin command descriptor. -var Cmd = builtins.Command{Name: "sort", Description: "sort lines of text files", MakeFlags: registerFlags} +var Cmd = builtins.Command{ + Name: "sort", + Description: "sort lines of text files", + Help: `Usage: sort [OPTION]... [FILE]... +Write sorted concatenation of all FILE(s) to standard output. +With no FILE, or when FILE is -, read standard input. + + -c, --check string check for sorted input; optionally =silent or =quiet + -d, --dictionary-order consider only blanks and alphanumeric characters + -t, --field-separator string use SEP as the field separator + -h, --help print usage and exit + -f, --ignore-case fold lower case to upper case characters + -b, --ignore-leading-blanks ignore leading blanks + -k, --key stringArray sort via a key; KEYDEF is F[.C][OPTS][,F[.C][OPTS]] + -n, --numeric-sort compare according to string numerical value + -r, --reverse reverse the result of comparisons + -s, --stable stabilize sort by disabling last-resort comparison + -u, --unique output only the first of an equal run`, + MakeFlags: registerFlags, +} // checkTracker is a pflag.Value that tracks all --check/-c modes set // during argument parsing so conflicting modes (diagnose vs silent) can diff --git a/builtins/ss/ss.go b/builtins/ss/ss.go index 47e4dbc6..2e1faeb3 100644 --- a/builtins/ss/ss.go +++ b/builtins/ss/ss.go @@ -104,7 +104,27 @@ import ( ) // Cmd is the ss builtin command descriptor. -var Cmd = builtins.Command{Name: "ss", MakeFlags: registerFlags} +var Cmd = builtins.Command{ + Name: "ss", + Description: "display socket statistics", + Help: `Usage: ss [OPTION]... +Display information about network sockets. + + -a, --all display all sockets (listening and non-listening) + -e, --extended show extended socket info (uid, inode) + -h, --help print usage and exit + -4, --ipv4 display only IPv4 sockets + -6, --ipv6 display only IPv6 sockets + -l, --listening display only listening sockets + -H, --no-header suppress column header + -n, --numeric do not resolve service names + -o, --options show timer information + -s, --summary print summary statistics only + -t, --tcp display only TCP sockets + -u, --udp display only UDP sockets + -x, --unix display only Unix domain sockets`, + MakeFlags: registerFlags, +} // MaxLineBytes is the per-line buffer cap for the Linux /proc/net/ scanner. const MaxLineBytes = 1 << 20 // 1 MiB diff --git a/builtins/strings_cmd/strings.go b/builtins/strings_cmd/strings.go index 036ef4a5..40fcab50 100644 --- a/builtins/strings_cmd/strings.go +++ b/builtins/strings_cmd/strings.go @@ -72,7 +72,22 @@ import ( ) // Cmd is the strings builtin command descriptor. -var Cmd = builtins.Command{Name: "strings", Description: "print printable character sequences", MakeFlags: registerFlags} +var Cmd = builtins.Command{ + Name: "strings", + Description: "print printable character sequences", + Help: `Usage: strings [OPTION]... [FILE]... +Print printable character sequences in files. +With no FILE, or when FILE is -, read standard input. + + -a, --all scan entire file (default; accepted for POSIX compatibility) + -n, --bytes int minimum string length (default 4) + -h, --help print usage and exit + -o, --offset-octal alias for -t o (print octal offsets) + -s, --output-separator string output separator between strings (default newline) + -f, --print-file-name print file name before each string + -t, --radix string print file offset in given radix: o=octal, d=decimal, x=hex`, + MakeFlags: registerFlags, +} const ( defaultMinLen = 4 diff --git a/builtins/tail/tail.go b/builtins/tail/tail.go index f623401d..651faa71 100644 --- a/builtins/tail/tail.go +++ b/builtins/tail/tail.go @@ -85,7 +85,22 @@ import ( ) // Cmd is the tail builtin command descriptor. -var Cmd = builtins.Command{Name: "tail", Description: "output the last part of files", MakeFlags: registerFlags} +var Cmd = builtins.Command{ + Name: "tail", + Description: "output the last part of files", + Help: `Usage: tail [OPTION]... [FILE]... +Print the last 10 lines of each FILE to standard output. +With no FILE, or when FILE is -, read standard input. + + -c, --bytes string output the last N bytes instead of lines + -h, --help print usage and exit + -n, --lines string output the last N lines instead of the last 10 + -q, --quiet never print file name headers + --silent alias for --quiet + -v, --verbose always print file name headers + -z, --zero-terminated use NUL as line delimiter`, + MakeFlags: registerFlags, +} // MaxCount is the maximum accepted line or byte count. Values above this // are clamped to prevent huge theoretical allocations. diff --git a/builtins/testcmd/testcmd.go b/builtins/testcmd/testcmd.go index 9712eb59..076c51f2 100644 --- a/builtins/testcmd/testcmd.go +++ b/builtins/testcmd/testcmd.go @@ -81,10 +81,122 @@ import ( ) // Cmd is the "test" builtin command registration. -var Cmd = builtins.Command{Name: "test", Description: "evaluate conditional expression", MakeFlags: builtins.NoFlags(runTest)} +var Cmd = builtins.Command{ + Name: "test", + Description: "evaluate conditional expression", + Help: `Usage: test EXPRESSION + or: [ EXPRESSION ] + +Evaluate conditional expression. + +Exit status: + 0 if EXPRESSION is true, + 1 if EXPRESSION is false, + 2 if an error occurred. + +File tests: + -a FILE FILE exists (deprecated synonym for -e) + -e FILE FILE exists + -f FILE FILE is a regular file + -d FILE FILE is a directory + -s FILE FILE has size > 0 + -r FILE FILE is readable + -w FILE FILE is writable + -x FILE FILE is executable + -h FILE FILE is a symbolic link + -L FILE FILE is a symbolic link (same as -h) + -p FILE FILE is a named pipe + +File comparison: + FILE1 -nt FILE2 FILE1 is newer than FILE2 + FILE1 -ot FILE2 FILE1 is older than FILE2 + +String tests: + -z STRING STRING has zero length + -n STRING STRING has non-zero length + STRING STRING is non-empty + +String comparison: + S1 = S2 strings are equal + S1 == S2 strings are equal (synonym for =) + S1 != S2 strings are not equal + S1 < S2 S1 sorts before S2 + S1 > S2 S1 sorts after S2 + +Integer comparison: + N1 -eq N2 N1 equals N2 + N1 -ne N2 N1 is not equal to N2 + N1 -lt N2 N1 is less than N2 + N1 -le N2 N1 is less or equal to N2 + N1 -gt N2 N1 is greater than N2 + N1 -ge N2 N1 is greater or equal to N2 + +Logical: + ! EXPR EXPR is false + EXPR1 -a EXPR2 both true + EXPR1 -o EXPR2 either true + ( EXPR ) grouping`, + MakeFlags: builtins.NoFlags(runTest), +} // BracketCmd is the "[" builtin command registration. -var BracketCmd = builtins.Command{Name: "[", Description: "evaluate conditional expression", MakeFlags: builtins.NoFlags(runBracket)} +var BracketCmd = builtins.Command{ + Name: "[", + Description: "evaluate conditional expression", + Help: `Usage: test EXPRESSION + or: [ EXPRESSION ] + +Evaluate conditional expression. + +Exit status: + 0 if EXPRESSION is true, + 1 if EXPRESSION is false, + 2 if an error occurred. + +File tests: + -a FILE FILE exists (deprecated synonym for -e) + -e FILE FILE exists + -f FILE FILE is a regular file + -d FILE FILE is a directory + -s FILE FILE has size > 0 + -r FILE FILE is readable + -w FILE FILE is writable + -x FILE FILE is executable + -h FILE FILE is a symbolic link + -L FILE FILE is a symbolic link (same as -h) + -p FILE FILE is a named pipe + +File comparison: + FILE1 -nt FILE2 FILE1 is newer than FILE2 + FILE1 -ot FILE2 FILE1 is older than FILE2 + +String tests: + -z STRING STRING has zero length + -n STRING STRING has non-zero length + STRING STRING is non-empty + +String comparison: + S1 = S2 strings are equal + S1 == S2 strings are equal (synonym for =) + S1 != S2 strings are not equal + S1 < S2 S1 sorts before S2 + S1 > S2 S1 sorts after S2 + +Integer comparison: + N1 -eq N2 N1 equals N2 + N1 -ne N2 N1 is not equal to N2 + N1 -lt N2 N1 is less than N2 + N1 -le N2 N1 is less or equal to N2 + N1 -gt N2 N1 is greater than N2 + N1 -ge N2 N1 is greater or equal to N2 + +Logical: + ! EXPR EXPR is false + EXPR1 -a EXPR2 both true + EXPR1 -o EXPR2 either true + ( EXPR ) grouping`, + MakeFlags: builtins.NoFlags(runBracket), +} const helpText = `Usage: test EXPRESSION or: [ EXPRESSION ] diff --git a/builtins/tests/help/help_test.go b/builtins/tests/help/help_test.go index afa5bbd4..9ff0b31b 100644 --- a/builtins/tests/help/help_test.go +++ b/builtins/tests/help/help_test.go @@ -114,14 +114,14 @@ func TestHelpIncludesDescriptions(t *testing.T) { // Spot-check a few descriptions. assert.Contains(t, stdout, "concatenate and print files") assert.Contains(t, stdout, "write arguments to stdout") - assert.Contains(t, stdout, "display available commands") + assert.Contains(t, stdout, "display help for commands") assert.Contains(t, stdout, "list directory contents") } func TestHelpIncludesFooterHint(t *testing.T) { stdout, _, code := runScript(t, "help", "", interp.AllowAllCommands()) assert.Equal(t, 0, code) - assert.Contains(t, stdout, "Run ' --help' for more information on a specific command.") + assert.Contains(t, stdout, "Run 'help ' for more information on a specific command.") } func TestHelpColumnsAligned(t *testing.T) { @@ -234,23 +234,23 @@ func TestHelpAlwaysAvailableNoCommands(t *testing.T) { // --- Error handling --- -func TestHelpRejectsArguments(t *testing.T) { - stdout, _, code := runScript(t, "help foo", "", interp.AllowAllCommands()) +func TestHelpUnknownCommandShowsError(t *testing.T) { + _, stderr, code := runScript(t, "help foo", "", interp.AllowAllCommands()) assert.Equal(t, 1, code) - assert.Contains(t, stdout, "Usage: help") + assert.Contains(t, stderr, "no help topics match 'foo'") } -func TestHelpRejectsMultipleArguments(t *testing.T) { - stdout, _, code := runScript(t, "help foo bar baz", "", interp.AllowAllCommands()) - assert.Equal(t, 1, code) - assert.Contains(t, stdout, "Usage: help") +func TestHelpShowsCommandHelp(t *testing.T) { + stdout, _, code := runScript(t, "help echo", "", interp.AllowAllCommands()) + assert.Equal(t, 0, code) + assert.Contains(t, stdout, "echo: echo [-neE]") } func TestHelpFlagPrintsUsage(t *testing.T) { stdout, _, code := runScript(t, "help --help", "", interp.AllowAllCommands()) assert.Equal(t, 1, code) assert.Contains(t, stdout, "Usage: help") - assert.Contains(t, stdout, "Takes no arguments.") + assert.Contains(t, stdout, "Display help for builtin commands.") } func TestHelpUnknownFlagRejected(t *testing.T) { @@ -287,7 +287,7 @@ func TestHelpListsItself(t *testing.T) { stdout, _, code := runScript(t, "help", "", interp.AllowAllCommands()) assert.Equal(t, 0, code) assert.Contains(t, stdout, "help") - assert.Contains(t, stdout, "display available commands") + assert.Contains(t, stdout, "display help for commands") } // --- Empty stderr on success --- diff --git a/builtins/tr/tr.go b/builtins/tr/tr.go index 1db00d8d..f8521f24 100644 --- a/builtins/tr/tr.go +++ b/builtins/tr/tr.go @@ -65,7 +65,20 @@ import ( ) // Cmd is the tr builtin command descriptor. -var Cmd = builtins.Command{Name: "tr", Description: "translate or delete characters", MakeFlags: registerFlags} +var Cmd = builtins.Command{ + Name: "tr", + Description: "translate or delete characters", + Help: `Usage: tr [OPTION]... SET1 [SET2] +Translate, squeeze, and/or delete characters from standard input, +writing to standard output. + + -c, --complement use complement of SET1 + -d, --delete delete characters in SET1 + -h, --help print usage and exit + -s, --squeeze-repeats squeeze repeated characters + -t, --truncate-set1 truncate SET1 to length of SET2`, + MakeFlags: registerFlags, +} const readBufSize = 32 * 1024 diff --git a/builtins/true/true.go b/builtins/true/true.go index 36c6155a..d77e299e 100644 --- a/builtins/true/true.go +++ b/builtins/true/true.go @@ -23,7 +23,15 @@ import ( ) // Cmd is the true builtin command descriptor. -var Cmd = builtins.Command{Name: "true", Description: "return successful exit status", MakeFlags: builtins.NoFlags(run)} +var Cmd = builtins.Command{ + Name: "true", + Description: "return successful exit status", + Help: `true: true + Return a successful result. + + Exit with a status code indicating success.`, + MakeFlags: builtins.NoFlags(run), +} func run(_ context.Context, _ *builtins.CallContext, _ []string) builtins.Result { return builtins.Result{} diff --git a/builtins/uniq/uniq.go b/builtins/uniq/uniq.go index 4af72a27..12e75c6e 100644 --- a/builtins/uniq/uniq.go +++ b/builtins/uniq/uniq.go @@ -85,7 +85,26 @@ import ( ) // Cmd is the uniq builtin command descriptor. -var Cmd = builtins.Command{Name: "uniq", Description: "report or omit repeated lines", MakeFlags: registerFlags} +var Cmd = builtins.Command{ + Name: "uniq", + Description: "report or omit repeated lines", + Help: `Usage: uniq [OPTION]... [INPUT] +Filter adjacent matching lines from INPUT (or stdin), +writing to standard output. + + -D, --all-repeated string print all duplicate lines + -w, --check-chars string compare no more than N characters + -c, --count prefix lines by the number of occurrences + --group string show all input lines with group separators + -h, --help print usage and exit + -i, --ignore-case ignore differences in case when comparing + -d, --repeated only print duplicate lines, one for each group + -s, --skip-chars string avoid comparing the first N characters + -f, --skip-fields string avoid comparing the first N fields + -u, --unique only print unique lines + -z, --zero-terminated line delimiter is NUL, not newline`, + MakeFlags: registerFlags, +} // MaxLineBytes is the per-line buffer cap for the line scanner. const MaxLineBytes = 1 << 20 // 1 MiB diff --git a/builtins/wc/wc.go b/builtins/wc/wc.go index c9839bce..d4e649fb 100644 --- a/builtins/wc/wc.go +++ b/builtins/wc/wc.go @@ -68,7 +68,21 @@ import ( ) // Cmd is the wc builtin command descriptor. -var Cmd = builtins.Command{Name: "wc", Description: "print newline, word, and byte counts", MakeFlags: registerFlags} +var Cmd = builtins.Command{ + Name: "wc", + Description: "print newline, word, and byte counts", + Help: `Usage: wc [OPTION]... [FILE]... +Print newline, word, and byte counts for each FILE. +With no FILE, or when FILE is -, read standard input. + + -c, --bytes print the byte counts + -m, --chars print the character counts + --help print usage and exit + -l, --lines print the newline counts + -L, --max-line-length print the maximum display width + -w, --words print the word counts`, + MakeFlags: registerFlags, +} const chunkSize = 32 * 1024 // 32 KiB read buffer const nonRegularMinWidth = 7 // GNU wc minimum column width for non-regular files diff --git a/tests/scenarios/cmd/help/restricted_commands.yaml b/tests/scenarios/cmd/help/restricted_commands.yaml index cf781169..16883e20 100644 --- a/tests/scenarios/cmd/help/restricted_commands.yaml +++ b/tests/scenarios/cmd/help/restricted_commands.yaml @@ -7,8 +7,8 @@ input: expect: stdout: | echo write arguments to stdout - help display available commands + help display help for commands - Run ' --help' for more information on a specific command. + Run 'help ' for more information on a specific command. stderr: "" exit_code: 0 diff --git a/tests/scenarios/cmd/help/too_many_args.yaml b/tests/scenarios/cmd/help/too_many_args.yaml index c71343d5..c6e6a8e3 100644 --- a/tests/scenarios/cmd/help/too_many_args.yaml +++ b/tests/scenarios/cmd/help/too_many_args.yaml @@ -1,9 +1,9 @@ -description: Help prints usage when arguments are provided. +description: Help prints error for unknown command. skip_assert_against_bash: true input: script: |+ help foo expect: - stdout_contains: ["Usage: help"] - stderr: "" + stdout: "" + stderr: "help: no help topics match 'foo'\n" exit_code: 1 From 142fb22a91c85cb2189ae4580e2fcf2a9d774592 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Thu, 19 Mar 2026 10:19:23 -0400 Subject: [PATCH 06/10] fix: write builtin --help text to stdout, not stderr Bash emits builtin --help output on stdout. Our break, continue, and exit builtins incorrectly wrote to stderr, breaking patterns like 'break --help | head' and 'break --help >file'. Co-Authored-By: Claude Opus 4.6 (1M context) --- builtins/break/break.go | 2 +- builtins/continue/continue.go | 2 +- builtins/exit/exit.go | 2 +- tests/scenarios/cmd/exit/help.yaml | 4 ++-- tests/scenarios/shell/for_clause/break_cont/break_help.yaml | 4 ++-- .../scenarios/shell/for_clause/break_cont/continue_help.yaml | 4 ++-- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/builtins/break/break.go b/builtins/break/break.go index 31fe886b..b75bc01b 100644 --- a/builtins/break/break.go +++ b/builtins/break/break.go @@ -42,7 +42,7 @@ var Cmd = builtins.Command{ func run(_ context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { if len(args) > 0 && args[0] == "--help" { - callCtx.Errf("break: break [n]\n Exit for, while, or until loops.\n\n Exit a FOR, WHILE or UNTIL loop. If N is specified, break N enclosing\n loops.\n\n Exit Status:\n The exit status is 0 unless N is not greater than or equal to 1.\n") + callCtx.Outf("break: break [n]\n Exit for, while, or until loops.\n\n Exit a FOR, WHILE or UNTIL loop. If N is specified, break N enclosing\n loops.\n\n Exit Status:\n The exit status is 0 unless N is not greater than or equal to 1.\n") return builtins.Result{Code: 2} } return loopctl.LoopControl(callCtx, "break", args) diff --git a/builtins/continue/continue.go b/builtins/continue/continue.go index edd33d84..8f3d2ca4 100644 --- a/builtins/continue/continue.go +++ b/builtins/continue/continue.go @@ -42,7 +42,7 @@ var Cmd = builtins.Command{ func run(_ context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { if len(args) > 0 && args[0] == "--help" { - callCtx.Errf("continue: continue [n]\n Resume for, while, or until loops.\n\n Resumes the next iteration of the enclosing FOR, WHILE or UNTIL loop.\n If N is specified, resumes the Nth enclosing loop.\n\n Exit Status:\n The exit status is 0 unless N is not greater than or equal to 1.\n") + callCtx.Outf("continue: continue [n]\n Resume for, while, or until loops.\n\n Resumes the next iteration of the enclosing FOR, WHILE or UNTIL loop.\n If N is specified, resumes the Nth enclosing loop.\n\n Exit Status:\n The exit status is 0 unless N is not greater than or equal to 1.\n") return builtins.Result{Code: 2} } return loopctl.LoopControl(callCtx, "continue", args) diff --git a/builtins/exit/exit.go b/builtins/exit/exit.go index 496342dc..d3f33e2f 100644 --- a/builtins/exit/exit.go +++ b/builtins/exit/exit.go @@ -41,7 +41,7 @@ var Cmd = builtins.Command{ func run(_ context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { if len(args) > 0 && args[0] == "--help" { - callCtx.Errf("exit: exit [n]\n Exit the shell.\n\n Exits the shell with a status of N. If N is omitted, the exit status\n is that of the last command executed.\n") + callCtx.Outf("exit: exit [n]\n Exit the shell.\n\n Exits the shell with a status of N. If N is omitted, the exit status\n is that of the last command executed.\n") return builtins.Result{Code: 2} } var r builtins.Result diff --git a/tests/scenarios/cmd/exit/help.yaml b/tests/scenarios/cmd/exit/help.yaml index 8dda5822..e2c6b186 100644 --- a/tests/scenarios/cmd/exit/help.yaml +++ b/tests/scenarios/cmd/exit/help.yaml @@ -6,11 +6,11 @@ input: echo "still running" expect: stdout: |+ - still running - stderr: |+ exit: exit [n] Exit the shell. Exits the shell with a status of N. If N is omitted, the exit status is that of the last command executed. + still running + stderr: "" exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/break_cont/break_help.yaml b/tests/scenarios/shell/for_clause/break_cont/break_help.yaml index 5ca2da8e..baa7e811 100644 --- a/tests/scenarios/shell/for_clause/break_cont/break_help.yaml +++ b/tests/scenarios/shell/for_clause/break_cont/break_help.yaml @@ -4,8 +4,7 @@ input: script: |+ for i in 1; do break --help; done expect: - stdout: "" - stderr: |+ + stdout: |+ break: break [n] Exit for, while, or until loops. @@ -14,4 +13,5 @@ expect: Exit Status: The exit status is 0 unless N is not greater than or equal to 1. + stderr: "" exit_code: 2 diff --git a/tests/scenarios/shell/for_clause/break_cont/continue_help.yaml b/tests/scenarios/shell/for_clause/break_cont/continue_help.yaml index 0278455c..f12ee5f2 100644 --- a/tests/scenarios/shell/for_clause/break_cont/continue_help.yaml +++ b/tests/scenarios/shell/for_clause/break_cont/continue_help.yaml @@ -4,8 +4,7 @@ input: script: |+ for i in 1; do continue --help; done expect: - stdout: "" - stderr: |+ + stdout: |+ continue: continue [n] Resume for, while, or until loops. @@ -14,4 +13,5 @@ expect: Exit Status: The exit status is 0 unless N is not greater than or equal to 1. + stderr: "" exit_code: 2 From 16731f760dc87b7186d7cef084f504d025845e6d Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Thu, 19 Mar 2026 10:26:50 -0400 Subject: [PATCH 07/10] fix: match bash byte-for-byte for builtin --help output Re-enable bash comparison assertions for break, continue, and exit help scenarios. Add trailing spaces on blank lines to match bash's exact output format. All three scenarios now pass byte-for-byte against debian:bookworm-slim bash. Co-Authored-By: Claude Opus 4.6 (1M context) --- builtins/break/break.go | 2 +- builtins/continue/continue.go | 2 +- builtins/exit/exit.go | 2 +- tests/scenarios/cmd/exit/help.yaml | 9 +-------- .../shell/for_clause/break_cont/break_help.yaml | 11 +---------- .../shell/for_clause/break_cont/continue_help.yaml | 11 +---------- 6 files changed, 6 insertions(+), 31 deletions(-) diff --git a/builtins/break/break.go b/builtins/break/break.go index b75bc01b..190d2e5d 100644 --- a/builtins/break/break.go +++ b/builtins/break/break.go @@ -42,7 +42,7 @@ var Cmd = builtins.Command{ func run(_ context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { if len(args) > 0 && args[0] == "--help" { - callCtx.Outf("break: break [n]\n Exit for, while, or until loops.\n\n Exit a FOR, WHILE or UNTIL loop. If N is specified, break N enclosing\n loops.\n\n Exit Status:\n The exit status is 0 unless N is not greater than or equal to 1.\n") + callCtx.Outf("break: break [n]\n Exit for, while, or until loops.\n \n Exit a FOR, WHILE or UNTIL loop. If N is specified, break N enclosing\n loops.\n \n Exit Status:\n The exit status is 0 unless N is not greater than or equal to 1.\n") return builtins.Result{Code: 2} } return loopctl.LoopControl(callCtx, "break", args) diff --git a/builtins/continue/continue.go b/builtins/continue/continue.go index 8f3d2ca4..c022e5f4 100644 --- a/builtins/continue/continue.go +++ b/builtins/continue/continue.go @@ -42,7 +42,7 @@ var Cmd = builtins.Command{ func run(_ context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { if len(args) > 0 && args[0] == "--help" { - callCtx.Outf("continue: continue [n]\n Resume for, while, or until loops.\n\n Resumes the next iteration of the enclosing FOR, WHILE or UNTIL loop.\n If N is specified, resumes the Nth enclosing loop.\n\n Exit Status:\n The exit status is 0 unless N is not greater than or equal to 1.\n") + callCtx.Outf("continue: continue [n]\n Resume for, while, or until loops.\n \n Resumes the next iteration of the enclosing FOR, WHILE or UNTIL loop.\n If N is specified, resumes the Nth enclosing loop.\n \n Exit Status:\n The exit status is 0 unless N is not greater than or equal to 1.\n") return builtins.Result{Code: 2} } return loopctl.LoopControl(callCtx, "continue", args) diff --git a/builtins/exit/exit.go b/builtins/exit/exit.go index d3f33e2f..b1fc499b 100644 --- a/builtins/exit/exit.go +++ b/builtins/exit/exit.go @@ -41,7 +41,7 @@ var Cmd = builtins.Command{ func run(_ context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { if len(args) > 0 && args[0] == "--help" { - callCtx.Outf("exit: exit [n]\n Exit the shell.\n\n Exits the shell with a status of N. If N is omitted, the exit status\n is that of the last command executed.\n") + callCtx.Outf("exit: exit [n]\n Exit the shell.\n \n Exits the shell with a status of N. If N is omitted, the exit status\n is that of the last command executed.\n") return builtins.Result{Code: 2} } var r builtins.Result diff --git a/tests/scenarios/cmd/exit/help.yaml b/tests/scenarios/cmd/exit/help.yaml index e2c6b186..b4fc4296 100644 --- a/tests/scenarios/cmd/exit/help.yaml +++ b/tests/scenarios/cmd/exit/help.yaml @@ -1,16 +1,9 @@ description: Exit --help displays usage information without exiting the shell. -skip_assert_against_bash: true # bash outputs slightly different formatting input: script: |+ exit --help echo "still running" expect: - stdout: |+ - exit: exit [n] - Exit the shell. - - Exits the shell with a status of N. If N is omitted, the exit status - is that of the last command executed. - still running + stdout: "exit: exit [n]\n Exit the shell.\n \n Exits the shell with a status of N. If N is omitted, the exit status\n is that of the last command executed.\nstill running\n" stderr: "" exit_code: 0 diff --git a/tests/scenarios/shell/for_clause/break_cont/break_help.yaml b/tests/scenarios/shell/for_clause/break_cont/break_help.yaml index baa7e811..c3fbd987 100644 --- a/tests/scenarios/shell/for_clause/break_cont/break_help.yaml +++ b/tests/scenarios/shell/for_clause/break_cont/break_help.yaml @@ -1,17 +1,8 @@ description: Break --help displays usage information. -skip_assert_against_bash: true # bash outputs slightly different formatting input: script: |+ for i in 1; do break --help; done expect: - stdout: |+ - break: break [n] - Exit for, while, or until loops. - - Exit a FOR, WHILE or UNTIL loop. If N is specified, break N enclosing - loops. - - Exit Status: - The exit status is 0 unless N is not greater than or equal to 1. + stdout: "break: break [n]\n Exit for, while, or until loops.\n \n Exit a FOR, WHILE or UNTIL loop. If N is specified, break N enclosing\n loops.\n \n Exit Status:\n The exit status is 0 unless N is not greater than or equal to 1.\n" stderr: "" exit_code: 2 diff --git a/tests/scenarios/shell/for_clause/break_cont/continue_help.yaml b/tests/scenarios/shell/for_clause/break_cont/continue_help.yaml index f12ee5f2..8692ed5d 100644 --- a/tests/scenarios/shell/for_clause/break_cont/continue_help.yaml +++ b/tests/scenarios/shell/for_clause/break_cont/continue_help.yaml @@ -1,17 +1,8 @@ description: Continue --help displays usage information. -skip_assert_against_bash: true # bash outputs slightly different formatting input: script: |+ for i in 1; do continue --help; done expect: - stdout: |+ - continue: continue [n] - Resume for, while, or until loops. - - Resumes the next iteration of the enclosing FOR, WHILE or UNTIL loop. - If N is specified, resumes the Nth enclosing loop. - - Exit Status: - The exit status is 0 unless N is not greater than or equal to 1. + stdout: "continue: continue [n]\n Resume for, while, or until loops.\n \n Resumes the next iteration of the enclosing FOR, WHILE or UNTIL loop.\n If N is specified, resumes the Nth enclosing loop.\n \n Exit Status:\n The exit status is 0 unless N is not greater than or equal to 1.\n" stderr: "" exit_code: 2 From 48e3c72a05d26987555bc383539d3fd485701f5e Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Thu, 19 Mar 2026 10:49:40 -0400 Subject: [PATCH 08/10] refactor(help): dynamically capture --help output instead of static strings Replace static Help field strings on 23 commands with dynamic invocation: help now calls the command's handler with --help and captures the output. Only echo, true, and false retain static Help text since they don't handle --help (matching bash builtin behaviour). This eliminates 418 lines of duplicated help text that would drift out of sync whenever flags are added or changed. Co-Authored-By: Claude Opus 4.6 (1M context) --- allowedsymbols/symbols_builtins.go | 2 + builtins/break/break.go | 10 +-- builtins/cat/cat.go | 17 +---- builtins/continue/continue.go | 10 +-- builtins/cut/cut.go | 15 +--- builtins/exit/exit.go | 7 +- builtins/find/find.go | 45 +----------- builtins/grep/grep.go | 30 +------- builtins/head/head.go | 12 +--- builtins/help/help.go | 31 ++++++--- builtins/ip/ip.go | 18 +---- builtins/ls/ls.go | 21 +----- builtins/ping/ping.go | 16 +---- builtins/ps/ps.go | 10 +-- builtins/sed/sed.go | 11 +-- builtins/sort/sort.go | 17 +---- builtins/ss/ss.go | 18 +---- builtins/strings_cmd/strings.go | 13 +--- builtins/tail/tail.go | 13 +--- builtins/testcmd/testcmd.go | 108 +---------------------------- builtins/tr/tr.go | 11 +-- builtins/uniq/uniq.go | 17 +---- builtins/wc/wc.go | 12 +--- 23 files changed, 46 insertions(+), 418 deletions(-) diff --git a/allowedsymbols/symbols_builtins.go b/allowedsymbols/symbols_builtins.go index ab21830f..78d7e807 100644 --- a/allowedsymbols/symbols_builtins.go +++ b/allowedsymbols/symbols_builtins.go @@ -125,6 +125,7 @@ var builtinPerCommandSymbols = map[string][]string{ "strings.Split", // 🟢 splits a string by separator into a slice; pure function, no I/O. }, "help": { + "bytes.Buffer", // 🟢 in-memory buffer to capture --help output from commands; no I/O side effects. "context.Context", // 🟢 deadline/cancellation plumbing; pure interface, no side effects. }, "head": { @@ -388,6 +389,7 @@ var builtinAllowedSymbols = []string{ "bufio.NewScanner", // 🟢 line-by-line input reading (e.g. head, cat); no write or exec capability. "bufio.Scanner", // 🟢 scanner type for buffered input reading; no write or exec capability. "bufio.SplitFunc", // 🟢 type for custom scanner split functions; pure type, no I/O. + "bytes.Buffer", // 🟢 in-memory buffer to capture command output; no I/O side effects. "bytes.Equal", // 🟢 compares two byte slices for equality; pure function, no I/O. "bytes.IndexByte", // 🟢 finds a byte in a byte slice; pure function, no I/O. "bytes.NewReader", // 🟢 wraps a byte slice as an io.Reader; pure in-memory, no I/O. diff --git a/builtins/break/break.go b/builtins/break/break.go index 190d2e5d..b666ea58 100644 --- a/builtins/break/break.go +++ b/builtins/break/break.go @@ -29,15 +29,7 @@ import ( var Cmd = builtins.Command{ Name: "break", Description: "exit from a loop", - Help: `break: break [n] - Exit for, while, or until loops. - - Exit a FOR, WHILE or UNTIL loop. If N is specified, break N enclosing - loops. - - Exit Status: - The exit status is 0 unless N is not greater than or equal to 1.`, - MakeFlags: builtins.NoFlags(run), + MakeFlags: builtins.NoFlags(run), } func run(_ context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { diff --git a/builtins/cat/cat.go b/builtins/cat/cat.go index 3f78b37b..9b86473b 100644 --- a/builtins/cat/cat.go +++ b/builtins/cat/cat.go @@ -79,22 +79,7 @@ import ( var Cmd = builtins.Command{ Name: "cat", Description: "concatenate and print files", - Help: `Usage: cat [OPTION]... [FILE]... -Concatenate FILE(s) to standard output. -With no FILE, or when FILE is -, read standard input. - - --help print usage and exit - -n, --number number all output lines - -b, --number-nonblank number non-blank output lines, overrides -n - -A, --show-all equivalent to -vET - -E, --show-ends display $ at end of each line - -v, --show-nonprinting use ^ and M- notation, except for LFD and TAB - -e, --show-nonprinting-ends equivalent to -vE - -t, --show-nonprinting-tabs equivalent to -vT - -T, --show-tabs display TAB characters as ^I - -s, --squeeze-blank suppress repeated empty output lines - -u, --unbuffered ignored`, - MakeFlags: registerFlags, + MakeFlags: registerFlags, } // MaxLineBytes is the per-line buffer cap for the line scanner. Lines diff --git a/builtins/continue/continue.go b/builtins/continue/continue.go index c022e5f4..5225c184 100644 --- a/builtins/continue/continue.go +++ b/builtins/continue/continue.go @@ -29,15 +29,7 @@ import ( var Cmd = builtins.Command{ Name: "continue", Description: "continue a loop iteration", - Help: `continue: continue [n] - Resume for, while, or until loops. - - Resumes the next iteration of the enclosing FOR, WHILE or UNTIL loop. - If N is specified, resumes the Nth enclosing loop. - - Exit Status: - The exit status is 0 unless N is not greater than or equal to 1.`, - MakeFlags: builtins.NoFlags(run), + MakeFlags: builtins.NoFlags(run), } func run(_ context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { diff --git a/builtins/cut/cut.go b/builtins/cut/cut.go index 0b3b4237..08902904 100644 --- a/builtins/cut/cut.go +++ b/builtins/cut/cut.go @@ -78,20 +78,7 @@ import ( var Cmd = builtins.Command{ Name: "cut", Description: "remove sections from each line", - Help: `Usage: cut OPTION... [FILE]... -Print selected parts of lines from each FILE to standard output. -With no FILE, or when FILE is -, read standard input. - - -n, -- do not split multi-byte characters - -b, --bytes string select only these bytes - -c, --characters string select only these characters - --complement complement the set of selected bytes, characters, or fields - -d, --delimiter string use DELIM instead of TAB for field delimiter - -f, --fields string select only these fields - --help print usage and exit - -s, --only-delimited do not print lines not containing delimiters - --output-delimiter string use STRING as the output delimiter`, - MakeFlags: registerFlags, + MakeFlags: registerFlags, } // MaxLineBytes is the per-line buffer cap for the line scanner. diff --git a/builtins/exit/exit.go b/builtins/exit/exit.go index b1fc499b..49e12f3e 100644 --- a/builtins/exit/exit.go +++ b/builtins/exit/exit.go @@ -31,12 +31,7 @@ import ( var Cmd = builtins.Command{ Name: "exit", Description: "exit the shell", - Help: `exit: exit [n] - Exit the shell. - - Exits the shell with a status of N. If N is omitted, the exit status - is that of the last command executed.`, - MakeFlags: builtins.NoFlags(run), + MakeFlags: builtins.NoFlags(run), } func run(_ context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { diff --git a/builtins/find/find.go b/builtins/find/find.go index f886c30e..8e5248d8 100644 --- a/builtins/find/find.go +++ b/builtins/find/find.go @@ -100,50 +100,7 @@ const maxTraversalDepth = 256 var Cmd = builtins.Command{ Name: "find", Description: "search for files in a directory hierarchy", - Help: `Usage: find [-L] [-P] [path...] [expression] - -Search directory trees, evaluating an expression for each file found. -Default path is the current directory; default expression is -print. - -Options: - --help Print this help and exit. - -L Follow symbolic links. - -P Never follow symbolic links (default). - -Tests: - -name PATTERN Base name matches shell glob PATTERN. - -iname PATTERN Like -name but case-insensitive. - -path PATTERN Full path matches shell glob PATTERN. - -ipath PATTERN Like -path but case-insensitive. - -type TYPE File type: b,c,d,f,l,p,s. Comma-separated for OR. - -size N[cwbkMG] File size (+N=greater, -N=less, N=exact). - -empty Empty regular file or directory. - -newer FILE Modified more recently than FILE. - -mtime N Modified N days ago (+N=more, -N=less). - -mmin N Modified N minutes ago (+N=more, -N=less). - -perm MODE Permission bits match MODE (octal or symbolic). - -maxdepth N Descend at most N levels. - -mindepth N Apply tests only at depth >= N. - -true Always true. - -false Always false. - -Actions: - -print Print path followed by newline. - -print0 Print path followed by NUL. - -prune Skip directory subtree. - -quit Exit immediately. - -Operators: - ( EXPR ) Grouping. - ! EXPR / -not EXPR Negation. - EXPR -a EXPR / EXPR -and EXPR Conjunction (implicit). - EXPR -o EXPR / EXPR -or EXPR Disjunction. - -Blocked predicates [sandbox]: - -exec, -execdir, -delete, -ok, -okdir Execution/deletion. - -fls, -fprint, -fprint0, -fprintf File writes. - -regex, -iregex ReDoS risk.`, - MakeFlags: builtins.NoFlags(run), + MakeFlags: builtins.NoFlags(run), } func run(ctx context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { diff --git a/builtins/grep/grep.go b/builtins/grep/grep.go index 0d43ff83..d03f73d7 100644 --- a/builtins/grep/grep.go +++ b/builtins/grep/grep.go @@ -128,35 +128,7 @@ import ( var Cmd = builtins.Command{ Name: "grep", Description: "print lines that match patterns", - Help: `Usage: grep [OPTION]... PATTERN [FILE]... -Search for PATTERN in each FILE. -When FILE is -, read standard input. With no FILE, read standard input. - - -A, --after-context int print NUM lines after each match - -G, --basic-regexp use basic regular expressions (default) - -B, --before-context int print NUM lines before each match - -C, --context int print NUM lines of context around each match - -c, --count print only a count of matching lines per file - -E, --extended-regexp use extended regular expressions - -l, --files-with-matches print only names of files with matches - -L, --files-without-match print only names of files without matches - -F, --fixed-strings interpret pattern as fixed strings - --help print usage and exit - -i, --ignore-case ignore case distinctions - -v, --invert-match select non-matching lines - -n, --line-number prefix output with line numbers - -x, --line-regexp match only whole lines - -m, --max-count int stop after NUM matches per file - -h, --no-filename suppress filename prefix - -s, --no-messages suppress error messages - -o, --only-matching print only the matched parts - -q, --quiet suppress all output - -e, --regexp string use PATTERN as the pattern - --silent alias for --quiet - -a, --text process binary file as if it were text - -H, --with-filename always print filename prefix - -w, --word-regexp match only whole words`, - MakeFlags: registerFlags, + MakeFlags: registerFlags, } // MaxLineBytes is the per-line buffer cap for the line scanner. Lines diff --git a/builtins/head/head.go b/builtins/head/head.go index 317f9779..6d0fc330 100644 --- a/builtins/head/head.go +++ b/builtins/head/head.go @@ -63,17 +63,7 @@ import ( var Cmd = builtins.Command{ Name: "head", Description: "output the first part of files", - Help: `Usage: head [OPTION]... [FILE]... -Print the first 10 lines of each FILE to standard output. -With no FILE, or when FILE is -, read standard input. - - -c, --bytes string print the first N bytes instead of lines - -h, --help print usage and exit - -n, --lines string print the first N lines instead of the first 10 - -q, --quiet never print file name headers - --silent alias for --quiet - -v, --verbose always print file name headers`, - MakeFlags: registerFlags, + MakeFlags: registerFlags, } // MaxCount is the maximum accepted line or byte count. Values above this diff --git a/builtins/help/help.go b/builtins/help/help.go index 18cedc55..1447225e 100644 --- a/builtins/help/help.go +++ b/builtins/help/help.go @@ -20,6 +20,7 @@ package help import ( + "bytes" "context" "github.com/DataDog/rshell/builtins" @@ -29,12 +30,7 @@ import ( var Cmd = builtins.Command{ Name: "help", Description: "display help for commands", - Help: `Usage: help [command] -Display help for builtin commands. - -With no arguments, list all available commands with a brief description. -When COMMAND is given, display detailed help for that command.`, - MakeFlags: registerFlags, + MakeFlags: registerFlags, } func printUsage(callCtx *builtins.CallContext) { @@ -45,7 +41,7 @@ func printUsage(callCtx *builtins.CallContext) { func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { helpFlag := fs.Bool("help", false, "print usage and exit") - return func(_ context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { + return func(ctx context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { if *helpFlag { printUsage(callCtx) return builtins.Result{Code: 1} @@ -63,11 +59,28 @@ func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { callCtx.Errf("help: no help topics match '%s'\n", name) return builtins.Result{Code: 1} } + + // Use static Help text if available (for commands that don't + // handle --help, like echo, true, false). if meta.Help != "" { callCtx.Outf("%s\n", meta.Help) - } else { - callCtx.Outf("%s - %s\n", meta.Name, meta.Description) + return builtins.Result{} } + + // Otherwise, invoke the command with --help and capture the output. + if handler, ok := builtins.Lookup(name); ok && handler != nil { + var buf bytes.Buffer + captureCtx := *callCtx + captureCtx.Stdout = &buf + captureCtx.Stderr = &buf + handler(ctx, &captureCtx, []string{"--help"}) + if buf.Len() > 0 { + callCtx.Outf("%s", buf.String()) + return builtins.Result{} + } + } + + callCtx.Outf("%s - %s\n", meta.Name, meta.Description) return builtins.Result{} } diff --git a/builtins/ip/ip.go b/builtins/ip/ip.go index af8b1f08..80d192dc 100644 --- a/builtins/ip/ip.go +++ b/builtins/ip/ip.go @@ -91,23 +91,7 @@ import ( var Cmd = builtins.Command{ Name: "ip", Description: "show network interface information", - Help: `Usage: ip [GLOBAL-OPTIONS] OBJECT [COMMAND [ARGUMENTS]] -Show network interface information. - -Supported objects: - addr [show] [dev IFNAME] Show IP addresses - link [show] [dev IFNAME] Show link-layer information - -Global options: - --brief print brief information in tabular format - -h, --help print usage and exit - -4, --ipv4 show only IPv4 addresses - -6, --ipv6 show only IPv6 addresses - -o, --oneline output each record on a single line - -Note: -b/-B/-batch, -force, -n/--netns, and 'ip netns' are blocked for safety. -Note: the real ip command's -br flag is --brief in this builtin.`, - MakeFlags: registerFlags, + MakeFlags: registerFlags, } // displayOpts holds the resolved global display options. diff --git a/builtins/ls/ls.go b/builtins/ls/ls.go index 6edd250e..5d8d1cd0 100644 --- a/builtins/ls/ls.go +++ b/builtins/ls/ls.go @@ -102,26 +102,7 @@ var errFailed = errors.New("ls: one or more errors occurred") var Cmd = builtins.Command{ Name: "ls", Description: "list directory contents", - Help: `Usage: ls [OPTION]... [FILE]... -List directory contents. -List information about the FILEs (the current directory by default). - - -a, --all do not ignore entries starting with . - -A, --almost-all do not ignore . and .. - -d, --directory list directories themselves, not their contents - -r, --reverse reverse order while sorting - -S, --sort-size sort by file size, largest first - -t, --sort-time sort by modification time, newest first - -F, --classify append indicator to entries - -p, --append-slash append / indicator to directories - -R, --recursive list subdirectories recursively - -l, --long use a long listing format - -h, --human-readable with -l, print human-readable sizes - -1, --one list one file per line - --offset int skip first N entries (pagination) - --limit int show at most N entries (capped at MaxDirEntries) - --help print usage and exit`, - MakeFlags: registerFlags, + MakeFlags: registerFlags, } func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { diff --git a/builtins/ping/ping.go b/builtins/ping/ping.go index f2d24c48..122b54fc 100644 --- a/builtins/ping/ping.go +++ b/builtins/ping/ping.go @@ -129,21 +129,7 @@ const ( var Cmd = builtins.Command{ Name: "ping", Description: "send ICMP echo requests to a network host", - Help: `Usage: ping [OPTION]... HOST -Send ICMP echo requests to HOST and report statistics. - -Options: - -c, --count int number of ICMP packets to send (1-20) - -h, --help print usage and exit 0 - -i, --interval string interval between packets (200ms-1m0s) - -4, --ipv4 use IPv4 - -6, --ipv6 use IPv6 - -q, --quiet quiet output: suppress per-packet lines - -W, --wait string time to wait for each reply (100ms-30s) - -Note: the following flags are not supported for safety and will be rejected: - -f (flood), -b (broadcast), -s (packet size), -I (interface), -p (pattern), -R (record route)`, - MakeFlags: registerFlags, + MakeFlags: registerFlags, } func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { diff --git a/builtins/ps/ps.go b/builtins/ps/ps.go index 787ca4fe..051c62f2 100644 --- a/builtins/ps/ps.go +++ b/builtins/ps/ps.go @@ -54,15 +54,7 @@ import ( var Cmd = builtins.Command{ Name: "ps", Description: "report process status", - Help: `Usage: ps [-e|-A] [-f] [-p PIDLIST] -Report process status. - - -A, --All select all processes (same as -e) - -e, --all select all processes - -f, --full full-format listing - --help print usage and exit - -p, --pid string select by PID list (comma or space separated)`, - MakeFlags: registerFlags, + MakeFlags: registerFlags, } func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { diff --git a/builtins/sed/sed.go b/builtins/sed/sed.go index 46184b44..d8153238 100644 --- a/builtins/sed/sed.go +++ b/builtins/sed/sed.go @@ -119,16 +119,7 @@ import ( var Cmd = builtins.Command{ Name: "sed", Description: "stream editor for filtering and transforming text", - Help: `Usage: sed [OPTION]... [script] [FILE]... -Stream editor for filtering and transforming text. -With no FILE, or when FILE is -, read standard input. - - -e, --expression string add script commands - -h, --help print usage and exit - -n, --quiet suppress automatic printing of pattern space - -E, --regexp-extended use extended regular expressions - --silent alias for --quiet`, - MakeFlags: registerFlags, + MakeFlags: registerFlags, } // MaxLineBytes is the per-line buffer cap for the line scanner. diff --git a/builtins/sort/sort.go b/builtins/sort/sort.go index a07f326e..fe8e6da3 100644 --- a/builtins/sort/sort.go +++ b/builtins/sort/sort.go @@ -87,22 +87,7 @@ import ( var Cmd = builtins.Command{ Name: "sort", Description: "sort lines of text files", - Help: `Usage: sort [OPTION]... [FILE]... -Write sorted concatenation of all FILE(s) to standard output. -With no FILE, or when FILE is -, read standard input. - - -c, --check string check for sorted input; optionally =silent or =quiet - -d, --dictionary-order consider only blanks and alphanumeric characters - -t, --field-separator string use SEP as the field separator - -h, --help print usage and exit - -f, --ignore-case fold lower case to upper case characters - -b, --ignore-leading-blanks ignore leading blanks - -k, --key stringArray sort via a key; KEYDEF is F[.C][OPTS][,F[.C][OPTS]] - -n, --numeric-sort compare according to string numerical value - -r, --reverse reverse the result of comparisons - -s, --stable stabilize sort by disabling last-resort comparison - -u, --unique output only the first of an equal run`, - MakeFlags: registerFlags, + MakeFlags: registerFlags, } // checkTracker is a pflag.Value that tracks all --check/-c modes set diff --git a/builtins/ss/ss.go b/builtins/ss/ss.go index 2e1faeb3..23355a09 100644 --- a/builtins/ss/ss.go +++ b/builtins/ss/ss.go @@ -107,23 +107,7 @@ import ( var Cmd = builtins.Command{ Name: "ss", Description: "display socket statistics", - Help: `Usage: ss [OPTION]... -Display information about network sockets. - - -a, --all display all sockets (listening and non-listening) - -e, --extended show extended socket info (uid, inode) - -h, --help print usage and exit - -4, --ipv4 display only IPv4 sockets - -6, --ipv6 display only IPv6 sockets - -l, --listening display only listening sockets - -H, --no-header suppress column header - -n, --numeric do not resolve service names - -o, --options show timer information - -s, --summary print summary statistics only - -t, --tcp display only TCP sockets - -u, --udp display only UDP sockets - -x, --unix display only Unix domain sockets`, - MakeFlags: registerFlags, + MakeFlags: registerFlags, } // MaxLineBytes is the per-line buffer cap for the Linux /proc/net/ scanner. diff --git a/builtins/strings_cmd/strings.go b/builtins/strings_cmd/strings.go index 40fcab50..802dfd16 100644 --- a/builtins/strings_cmd/strings.go +++ b/builtins/strings_cmd/strings.go @@ -75,18 +75,7 @@ import ( var Cmd = builtins.Command{ Name: "strings", Description: "print printable character sequences", - Help: `Usage: strings [OPTION]... [FILE]... -Print printable character sequences in files. -With no FILE, or when FILE is -, read standard input. - - -a, --all scan entire file (default; accepted for POSIX compatibility) - -n, --bytes int minimum string length (default 4) - -h, --help print usage and exit - -o, --offset-octal alias for -t o (print octal offsets) - -s, --output-separator string output separator between strings (default newline) - -f, --print-file-name print file name before each string - -t, --radix string print file offset in given radix: o=octal, d=decimal, x=hex`, - MakeFlags: registerFlags, + MakeFlags: registerFlags, } const ( diff --git a/builtins/tail/tail.go b/builtins/tail/tail.go index 651faa71..4eeaff5b 100644 --- a/builtins/tail/tail.go +++ b/builtins/tail/tail.go @@ -88,18 +88,7 @@ import ( var Cmd = builtins.Command{ Name: "tail", Description: "output the last part of files", - Help: `Usage: tail [OPTION]... [FILE]... -Print the last 10 lines of each FILE to standard output. -With no FILE, or when FILE is -, read standard input. - - -c, --bytes string output the last N bytes instead of lines - -h, --help print usage and exit - -n, --lines string output the last N lines instead of the last 10 - -q, --quiet never print file name headers - --silent alias for --quiet - -v, --verbose always print file name headers - -z, --zero-terminated use NUL as line delimiter`, - MakeFlags: registerFlags, + MakeFlags: registerFlags, } // MaxCount is the maximum accepted line or byte count. Values above this diff --git a/builtins/testcmd/testcmd.go b/builtins/testcmd/testcmd.go index 076c51f2..909c6751 100644 --- a/builtins/testcmd/testcmd.go +++ b/builtins/testcmd/testcmd.go @@ -84,118 +84,14 @@ import ( var Cmd = builtins.Command{ Name: "test", Description: "evaluate conditional expression", - Help: `Usage: test EXPRESSION - or: [ EXPRESSION ] - -Evaluate conditional expression. - -Exit status: - 0 if EXPRESSION is true, - 1 if EXPRESSION is false, - 2 if an error occurred. - -File tests: - -a FILE FILE exists (deprecated synonym for -e) - -e FILE FILE exists - -f FILE FILE is a regular file - -d FILE FILE is a directory - -s FILE FILE has size > 0 - -r FILE FILE is readable - -w FILE FILE is writable - -x FILE FILE is executable - -h FILE FILE is a symbolic link - -L FILE FILE is a symbolic link (same as -h) - -p FILE FILE is a named pipe - -File comparison: - FILE1 -nt FILE2 FILE1 is newer than FILE2 - FILE1 -ot FILE2 FILE1 is older than FILE2 - -String tests: - -z STRING STRING has zero length - -n STRING STRING has non-zero length - STRING STRING is non-empty - -String comparison: - S1 = S2 strings are equal - S1 == S2 strings are equal (synonym for =) - S1 != S2 strings are not equal - S1 < S2 S1 sorts before S2 - S1 > S2 S1 sorts after S2 - -Integer comparison: - N1 -eq N2 N1 equals N2 - N1 -ne N2 N1 is not equal to N2 - N1 -lt N2 N1 is less than N2 - N1 -le N2 N1 is less or equal to N2 - N1 -gt N2 N1 is greater than N2 - N1 -ge N2 N1 is greater or equal to N2 - -Logical: - ! EXPR EXPR is false - EXPR1 -a EXPR2 both true - EXPR1 -o EXPR2 either true - ( EXPR ) grouping`, - MakeFlags: builtins.NoFlags(runTest), + MakeFlags: builtins.NoFlags(runTest), } // BracketCmd is the "[" builtin command registration. var BracketCmd = builtins.Command{ Name: "[", Description: "evaluate conditional expression", - Help: `Usage: test EXPRESSION - or: [ EXPRESSION ] - -Evaluate conditional expression. - -Exit status: - 0 if EXPRESSION is true, - 1 if EXPRESSION is false, - 2 if an error occurred. - -File tests: - -a FILE FILE exists (deprecated synonym for -e) - -e FILE FILE exists - -f FILE FILE is a regular file - -d FILE FILE is a directory - -s FILE FILE has size > 0 - -r FILE FILE is readable - -w FILE FILE is writable - -x FILE FILE is executable - -h FILE FILE is a symbolic link - -L FILE FILE is a symbolic link (same as -h) - -p FILE FILE is a named pipe - -File comparison: - FILE1 -nt FILE2 FILE1 is newer than FILE2 - FILE1 -ot FILE2 FILE1 is older than FILE2 - -String tests: - -z STRING STRING has zero length - -n STRING STRING has non-zero length - STRING STRING is non-empty - -String comparison: - S1 = S2 strings are equal - S1 == S2 strings are equal (synonym for =) - S1 != S2 strings are not equal - S1 < S2 S1 sorts before S2 - S1 > S2 S1 sorts after S2 - -Integer comparison: - N1 -eq N2 N1 equals N2 - N1 -ne N2 N1 is not equal to N2 - N1 -lt N2 N1 is less than N2 - N1 -le N2 N1 is less or equal to N2 - N1 -gt N2 N1 is greater than N2 - N1 -ge N2 N1 is greater or equal to N2 - -Logical: - ! EXPR EXPR is false - EXPR1 -a EXPR2 both true - EXPR1 -o EXPR2 either true - ( EXPR ) grouping`, - MakeFlags: builtins.NoFlags(runBracket), + MakeFlags: builtins.NoFlags(runBracket), } const helpText = `Usage: test EXPRESSION diff --git a/builtins/tr/tr.go b/builtins/tr/tr.go index f8521f24..43133d75 100644 --- a/builtins/tr/tr.go +++ b/builtins/tr/tr.go @@ -68,16 +68,7 @@ import ( var Cmd = builtins.Command{ Name: "tr", Description: "translate or delete characters", - Help: `Usage: tr [OPTION]... SET1 [SET2] -Translate, squeeze, and/or delete characters from standard input, -writing to standard output. - - -c, --complement use complement of SET1 - -d, --delete delete characters in SET1 - -h, --help print usage and exit - -s, --squeeze-repeats squeeze repeated characters - -t, --truncate-set1 truncate SET1 to length of SET2`, - MakeFlags: registerFlags, + MakeFlags: registerFlags, } const readBufSize = 32 * 1024 diff --git a/builtins/uniq/uniq.go b/builtins/uniq/uniq.go index 12e75c6e..0bb3c399 100644 --- a/builtins/uniq/uniq.go +++ b/builtins/uniq/uniq.go @@ -88,22 +88,7 @@ import ( var Cmd = builtins.Command{ Name: "uniq", Description: "report or omit repeated lines", - Help: `Usage: uniq [OPTION]... [INPUT] -Filter adjacent matching lines from INPUT (or stdin), -writing to standard output. - - -D, --all-repeated string print all duplicate lines - -w, --check-chars string compare no more than N characters - -c, --count prefix lines by the number of occurrences - --group string show all input lines with group separators - -h, --help print usage and exit - -i, --ignore-case ignore differences in case when comparing - -d, --repeated only print duplicate lines, one for each group - -s, --skip-chars string avoid comparing the first N characters - -f, --skip-fields string avoid comparing the first N fields - -u, --unique only print unique lines - -z, --zero-terminated line delimiter is NUL, not newline`, - MakeFlags: registerFlags, + MakeFlags: registerFlags, } // MaxLineBytes is the per-line buffer cap for the line scanner. diff --git a/builtins/wc/wc.go b/builtins/wc/wc.go index d4e649fb..a80059ad 100644 --- a/builtins/wc/wc.go +++ b/builtins/wc/wc.go @@ -71,17 +71,7 @@ import ( var Cmd = builtins.Command{ Name: "wc", Description: "print newline, word, and byte counts", - Help: `Usage: wc [OPTION]... [FILE]... -Print newline, word, and byte counts for each FILE. -With no FILE, or when FILE is -, read standard input. - - -c, --bytes print the byte counts - -m, --chars print the character counts - --help print usage and exit - -l, --lines print the newline counts - -L, --max-line-length print the maximum display width - -w, --words print the word counts`, - MakeFlags: registerFlags, + MakeFlags: registerFlags, } const chunkSize = 32 * 1024 // 32 KiB read buffer From cb478ab9482b433f97551e524d5d8a117dd4529d Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Thu, 19 Mar 2026 10:52:55 -0400 Subject: [PATCH 09/10] fix(help): reject extra operands in help command help echo typo now prints usage and exits 1 instead of silently ignoring the extra argument. Co-Authored-By: Claude Opus 4.6 (1M context) --- builtins/help/help.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/builtins/help/help.go b/builtins/help/help.go index 1447225e..2e6641df 100644 --- a/builtins/help/help.go +++ b/builtins/help/help.go @@ -48,6 +48,10 @@ func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { } // help — show detailed help for a specific command. + if len(args) > 1 { + printUsage(callCtx) + return builtins.Result{Code: 1} + } if len(args) > 0 { name := args[0] if callCtx.CommandAllowed != nil && !callCtx.CommandAllowed(name) { From 9d66875af0319cc37dbc9bc91e1cabf07fe3eb7f Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Thu, 19 Mar 2026 11:19:15 -0400 Subject: [PATCH 10/10] style: use readable helpText constants for inline --help strings Replace single-line escaped strings with multi-line string constants for break, continue, and exit --help output. Easier to read and maintain. Co-Authored-By: Claude Opus 4.6 (1M context) --- builtins/break/break.go | 11 ++++++++++- builtins/continue/continue.go | 11 ++++++++++- builtins/exit/exit.go | 8 +++++++- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/builtins/break/break.go b/builtins/break/break.go index b666ea58..1fdecfb9 100644 --- a/builtins/break/break.go +++ b/builtins/break/break.go @@ -25,6 +25,15 @@ import ( "github.com/DataDog/rshell/builtins/internal/loopctl" ) +const helpText = "break: break [n]\n" + + " Exit for, while, or until loops.\n" + + " \n" + + " Exit a FOR, WHILE or UNTIL loop. If N is specified, break N enclosing\n" + + " loops.\n" + + " \n" + + " Exit Status:\n" + + " The exit status is 0 unless N is not greater than or equal to 1." + // Cmd is the break builtin command descriptor. var Cmd = builtins.Command{ Name: "break", @@ -34,7 +43,7 @@ var Cmd = builtins.Command{ func run(_ context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { if len(args) > 0 && args[0] == "--help" { - callCtx.Outf("break: break [n]\n Exit for, while, or until loops.\n \n Exit a FOR, WHILE or UNTIL loop. If N is specified, break N enclosing\n loops.\n \n Exit Status:\n The exit status is 0 unless N is not greater than or equal to 1.\n") + callCtx.Outf("%s\n", helpText) return builtins.Result{Code: 2} } return loopctl.LoopControl(callCtx, "break", args) diff --git a/builtins/continue/continue.go b/builtins/continue/continue.go index 5225c184..ca107a79 100644 --- a/builtins/continue/continue.go +++ b/builtins/continue/continue.go @@ -25,6 +25,15 @@ import ( "github.com/DataDog/rshell/builtins/internal/loopctl" ) +const helpText = "continue: continue [n]\n" + + " Resume for, while, or until loops.\n" + + " \n" + + " Resumes the next iteration of the enclosing FOR, WHILE or UNTIL loop.\n" + + " If N is specified, resumes the Nth enclosing loop.\n" + + " \n" + + " Exit Status:\n" + + " The exit status is 0 unless N is not greater than or equal to 1." + // Cmd is the continue builtin command descriptor. var Cmd = builtins.Command{ Name: "continue", @@ -34,7 +43,7 @@ var Cmd = builtins.Command{ func run(_ context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { if len(args) > 0 && args[0] == "--help" { - callCtx.Outf("continue: continue [n]\n Resume for, while, or until loops.\n \n Resumes the next iteration of the enclosing FOR, WHILE or UNTIL loop.\n If N is specified, resumes the Nth enclosing loop.\n \n Exit Status:\n The exit status is 0 unless N is not greater than or equal to 1.\n") + callCtx.Outf("%s\n", helpText) return builtins.Result{Code: 2} } return loopctl.LoopControl(callCtx, "continue", args) diff --git a/builtins/exit/exit.go b/builtins/exit/exit.go index 49e12f3e..09aa44bf 100644 --- a/builtins/exit/exit.go +++ b/builtins/exit/exit.go @@ -27,6 +27,12 @@ import ( "github.com/DataDog/rshell/builtins" ) +const helpText = "exit: exit [n]\n" + + " Exit the shell.\n" + + " \n" + + " Exits the shell with a status of N. If N is omitted, the exit status\n" + + " is that of the last command executed." + // Cmd is the exit builtin command descriptor. var Cmd = builtins.Command{ Name: "exit", @@ -36,7 +42,7 @@ var Cmd = builtins.Command{ func run(_ context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { if len(args) > 0 && args[0] == "--help" { - callCtx.Outf("exit: exit [n]\n Exit the shell.\n \n Exits the shell with a status of N. If N is omitted, the exit status\n is that of the last command executed.\n") + callCtx.Outf("%s\n", helpText) return builtins.Result{Code: 2} } var r builtins.Result