diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b532f94..0952917e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ The latest `## [X.Y.Z]` header must match the `version` field in `punkproject.to Entries are newest-first; one bullet per notable change. See the root `AGENTS.md` "Project Versioning" section for the bump policy. +## [0.3.0] - 2026-07-07 + +- ` script` completed (goal G-015 achieved): runs a script file (`script ?args?`, conventional ::argv0/::argv) or the whole of piped stdin (` | script` — no trailing `exit` needed) in the default punk shell module/alias environment, so one-liners like `dev projects.work *name*` work with no boilerplate. Honest exit codes (0 success, 1 error with errorInfo on stderr, script's own `exit` honoured); no shellfilter transforms or logging side effects; never falls into an interactive shell. Implemented as the lean `app-punkscript` package; the stdin form echoes the script's final result when non-empty. Verified on both Tcl generations (punk902z, punksys). + ## [0.2.7] - 2026-07-07 - vendored tomlish 1.1.10: the library API now carries punk::args (PUNKARGS) documentation (from_toml, to_dict, from_dict, to_toml, update_tomlish_from_dict) — the G-014 tomlish precondition is met (documented upstream, re-vendored from a clean checkout); plus the explicit `tomlish::cmdline_main` entry point. diff --git a/GOALS.md b/GOALS.md index 93c94d27..30c01257 100644 --- a/GOALS.md +++ b/GOALS.md @@ -134,7 +134,7 @@ Detail: goals/G-014-punk-config-toml.md Goal: ::punk::config loads stored configuration from toml files in the XDG-located config dir - parsed via the vendored tomlish module, never an ad-hoc parser - and consumers resolve settings with per-named-subshell overrides, so features like the G-013 debug-view startup defaults read declared user configuration instead of hardcoded fallbacks. Acceptance: a setting declared in a toml file under the XDG-located config dir is visible through the punk::config API at repl startup, and with no config files present built-in defaults apply with no errors beyond the existing missing-dir notice; a named subshell resolves its own overriding value for a key also defined at the parent/default scope, and a subshell with no override inherits the outer value (proven with at least one real key); at least one shipped feature (the G-013 editbuf-view startup default is the natural first) reads its default through this path rather than a hardcoded value; all toml reading/writing in punk::config goes through the tomlish module, and the tomlish API procs punk::config consumes carry punk::args (PUNKARGS) documentation - added upstream in the tomlish project and re-vendored here before punk::config implementation proceeds. -### G-015 [active] Punk executable `script` subcommand: reliable non-interactive piped/script execution +### G-015 [achieved 2026-07-07] Punk executable `script` subcommand: reliable non-interactive piped/script execution Scope: src/vfs/_config/punk_main.tcl, src/lib/app-punkshell/punkshell.tcl (script path or a leaner dedicated app package) Detail: goals/G-015-script-subcommand-piped-stdin.md Goal: ` script [] [...]` executes a script file or piped stdin content (scriptname optional when input is piped) in an interp preloaded with the basic punk modules and aliases a punk shell provides by default, and always terminates with the script's success/failure as its exit code - without the `shell` subcommand's shellfilter channel transforms/logging stacks and without ever dropping into an interactive shell - so agents can reliably make piped script calls to punk executables. diff --git a/goals/G-015-script-subcommand-piped-stdin.md b/goals/G-015-script-subcommand-piped-stdin.md index 11fdbd99..0730b362 100644 --- a/goals/G-015-script-subcommand-piped-stdin.md +++ b/goals/G-015-script-subcommand-piped-stdin.md @@ -1,6 +1,6 @@ # G-015 Punk executable `script` subcommand: reliable non-interactive piped/script execution -Status: active (2026-07-07) +Status: achieved 2026-07-07 (implemented as src/lib/app-punkscript; verified on punk902z/tcl9 and punksys/tcl8.6 kits: piped success/error/exit-code cases, file form with args and stdin passthrough, and the motivating `dev projects.work *tomlish*` one-liner - table emitted, exit 0, no boilerplate. Stdin form additionally echoes the script's final result when non-empty, giving return-value commands one-liner ergonomics; file form keeps pure script semantics. Terminal-stdin-with-no-args yields a usage error by design - spot-check from a real console typed session remains a manual item.) Scope: src/vfs/_config/punk_main.tcl, src/lib/app-punkshell/punkshell.tcl (script path or a leaner dedicated app package) Acceptance: as in root GOALS.md index (canonical). diff --git a/punkproject.toml b/punkproject.toml index 597f47bf..0393c330 100644 --- a/punkproject.toml +++ b/punkproject.toml @@ -1,3 +1,3 @@ [project] name = "punkshell" -version = "0.2.7" +version = "0.3.0" diff --git a/src/lib/AGENTS.md b/src/lib/AGENTS.md index e4a0f958..75dcf927 100644 --- a/src/lib/AGENTS.md +++ b/src/lib/AGENTS.md @@ -16,6 +16,7 @@ Source of truth for all editable `pkgIndex.tcl`-based library packages. These ar - Libraries here include: - `app-punk/` — Punk REPL app entry (`app-punk::repl`) - `app-punkshell/` — Punkshell app entry (`app-punkshell`); on eof of its input channel the `env(PUNK_PIPE_EOF)` policy (`exit`|`interactive`|unset=console heuristic) decides between terminating and reopening the console for an interactive repl — automated callers should set `exit` or end piped input with an explicit `exit` + - `app-punkscript/` — lean one-shot script runner behind the punk executable `script` subcommand (G-015): runs a file or piped stdin in the default punk shell module/alias environment with honest exit codes; no shellfilter stacks or logging side effects, never falls into an interactive shell — the reliable path for automated/agent callers (prefer this over piping into `shell`); stdin form echoes the script's final result when non-empty - `app-shellspy/` — ShellSpy app entry (`app-shellspy`) - `app_shell/` — Shell app helpers (`app_shell`) - `app_shellrun/` — Shell run helpers (`app_shellrun`) @@ -37,6 +38,7 @@ Source of truth for all editable `pkgIndex.tcl`-based library packages. These ar - `app-punk/` — Punk REPL app entry - `app-punkshell/` — Punkshell app entry +- `app-punkscript/` — One-shot script runner (`script` subcommand) - `app-shellspy/` — ShellSpy app entry - `app_shell/` — Shell app helpers - `app_shellrun/` — Shell run helpers diff --git a/src/lib/app-punkscript/pkgIndex.tcl b/src/lib/app-punkscript/pkgIndex.tcl new file mode 100644 index 00000000..5ba2d15e --- /dev/null +++ b/src/lib/app-punkscript/pkgIndex.tcl @@ -0,0 +1 @@ +package ifneeded app-punkscript 1.0 [list source [file join $dir punkscript.tcl]] diff --git a/src/lib/app-punkscript/punkscript.tcl b/src/lib/app-punkscript/punkscript.tcl new file mode 100644 index 00000000..1d7dbc31 --- /dev/null +++ b/src/lib/app-punkscript/punkscript.tcl @@ -0,0 +1,97 @@ +package provide app-punkscript 1.0 +#Lean one-shot script runner for the punk executable 'script' subcommand (goal G-015). +# +#Contract (G-015 - see goals/G-015-script-subcommand-piped-stdin.md): +# - runs a script FILE (first argument, remaining arguments become the script's ::argv) +# or, with no arguments, the whole of piped/redirected stdin as the script. +# - the script interp carries the default punk shell module/alias environment +# (package punk registers the deck aliases such as 'dev'; punk::aliascore +# provides the utility aliases) so one-liners like 'dev projects.work *x*' +# need no package require boilerplate. +# - NO shellfilter channel stacks/transforms, no logging side effects, no repl, +# and no interactive fallback under any eof/error condition. This package is +# deliberately isolated from app-punkshell (share definitions via modules, +# never launcher control flow). +# - the launch plumbing emits nothing on stdout/stderr; only the script's own +# output (and, on error, the error report to stderr) appears. +# - exit codes: 0 on success, 1 on script error or usage error; a script's own +# explicit exit code is honoured. +# +#punk_main.tcl sets ::argv to the arguments after the 'script' subcommand +#before requiring this package (a bare non-subcommand first argument is also +#reclassified as 'script '). + +apply {{} { + #stdin-is-a-terminal detection, self-contained (no punkboot dependency so + #this package can also host in plain tclsh contexts). + #Windows consoles expose -inputmode on stdin; unix terminals expose -mode. + set stdin_is_terminal [apply {{} { + if {[catch {chan configure stdin} conf]} { + return 0 ;#no usable stdin at all - treat as non-terminal + } + expr {[dict exists $conf -inputmode] || [dict exists $conf -mode]} + }}] + + #default punk shell environment via shared definitions - no local alias lists + package require punk + package require punk::aliascore + punk::aliascore::init + + set arglist $::argv + if {[llength $arglist]} { + #file form: scriptname ?args...? + #works with or without piped stdin present - the script may read stdin itself + set scriptname [lindex $arglist 0] + set scriptargs [lrange $arglist 1 end] + set scriptpath [file normalize $scriptname] + if {![file exists $scriptpath]} { + puts stderr "punk script: script file not found: '$scriptpath'" + exit 1 + } + set ::argv0 $scriptpath + set ::argv $scriptargs + set ::argc [llength $scriptargs] + set ::tcl_interactive 0 + info script $scriptpath + if {[catch {uplevel #0 [list source $scriptpath]}]} { + puts stderr $::errorInfo + flush stderr + exit 1 + } + flush stdout + flush stderr + exit 0 + } else { + #stdin form: read the whole of piped/redirected stdin as the script. + #An interactive terminal with no scriptname is a usage error - never + #block on a console read and never fall through to a shell. + if {$stdin_is_terminal} { + puts stderr "punk script: no script file specified and stdin is a terminal" + puts stderr "usage: script ?args...?" + puts stderr " or: | script" + exit 1 + } + set script [read stdin] + set ::argv0 "-" + set ::argv {} + set ::argc 0 + set ::tcl_interactive 0 + if {[catch {uplevel #0 $script} result]} { + puts stderr $::errorInfo + flush stderr + exit 1 + } + #one-shot eval ergonomics for the stdin form only: the script's value + #(its final command's result) is written to stdout when non-empty, so + #one-liners whose commands RETURN their output (e.g 'dev projects.work + #' - displayed by the repl's result display in a shell) emit it + #here too without needing a puts wrapper. File-form scripts keep pure + #script semantics (results discarded). + if {[string length $result]} { + puts stdout $result + } + flush stdout + flush stderr + exit 0 + } +}} diff --git a/src/vfs/_config/punk_main.tcl b/src/vfs/_config/punk_main.tcl index 9558ece2..34e833db 100644 --- a/src/vfs/_config/punk_main.tcl +++ b/src/vfs/_config/punk_main.tcl @@ -1161,11 +1161,12 @@ apply { args { } } script { - #run a script and exit - puts stderr "main.tcl launching script with args: $subcommand_arglist" - puts stderr "tcl_interactive: $::tcl_interactive" - set ::tcl_interactive [punkboot::is_interactive] - puts stderr "tcl_interactive: $::tcl_interactive" + #run a script (file argument, or piped stdin when no argument) and exit - goal G-015 + #lean dedicated app package: default punk shell module/alias environment, + #no shellfilter stacks/transforms, no interactive fallback, honest exit codes. + #The launch plumbing must emit nothing on stdout/stderr (exec-style callers). + set ::tcl_interactive 0 + package require app-punkscript } shell { #app-punkshell supports running a script and maintaining an interactive shell afterwards diff --git a/src/vfs/_vfscommon.vfs/lib/app-punkscript/pkgIndex.tcl b/src/vfs/_vfscommon.vfs/lib/app-punkscript/pkgIndex.tcl new file mode 100644 index 00000000..5ba2d15e --- /dev/null +++ b/src/vfs/_vfscommon.vfs/lib/app-punkscript/pkgIndex.tcl @@ -0,0 +1 @@ +package ifneeded app-punkscript 1.0 [list source [file join $dir punkscript.tcl]] diff --git a/src/vfs/_vfscommon.vfs/lib/app-punkscript/punkscript.tcl b/src/vfs/_vfscommon.vfs/lib/app-punkscript/punkscript.tcl new file mode 100644 index 00000000..1d7dbc31 --- /dev/null +++ b/src/vfs/_vfscommon.vfs/lib/app-punkscript/punkscript.tcl @@ -0,0 +1,97 @@ +package provide app-punkscript 1.0 +#Lean one-shot script runner for the punk executable 'script' subcommand (goal G-015). +# +#Contract (G-015 - see goals/G-015-script-subcommand-piped-stdin.md): +# - runs a script FILE (first argument, remaining arguments become the script's ::argv) +# or, with no arguments, the whole of piped/redirected stdin as the script. +# - the script interp carries the default punk shell module/alias environment +# (package punk registers the deck aliases such as 'dev'; punk::aliascore +# provides the utility aliases) so one-liners like 'dev projects.work *x*' +# need no package require boilerplate. +# - NO shellfilter channel stacks/transforms, no logging side effects, no repl, +# and no interactive fallback under any eof/error condition. This package is +# deliberately isolated from app-punkshell (share definitions via modules, +# never launcher control flow). +# - the launch plumbing emits nothing on stdout/stderr; only the script's own +# output (and, on error, the error report to stderr) appears. +# - exit codes: 0 on success, 1 on script error or usage error; a script's own +# explicit exit code is honoured. +# +#punk_main.tcl sets ::argv to the arguments after the 'script' subcommand +#before requiring this package (a bare non-subcommand first argument is also +#reclassified as 'script '). + +apply {{} { + #stdin-is-a-terminal detection, self-contained (no punkboot dependency so + #this package can also host in plain tclsh contexts). + #Windows consoles expose -inputmode on stdin; unix terminals expose -mode. + set stdin_is_terminal [apply {{} { + if {[catch {chan configure stdin} conf]} { + return 0 ;#no usable stdin at all - treat as non-terminal + } + expr {[dict exists $conf -inputmode] || [dict exists $conf -mode]} + }}] + + #default punk shell environment via shared definitions - no local alias lists + package require punk + package require punk::aliascore + punk::aliascore::init + + set arglist $::argv + if {[llength $arglist]} { + #file form: scriptname ?args...? + #works with or without piped stdin present - the script may read stdin itself + set scriptname [lindex $arglist 0] + set scriptargs [lrange $arglist 1 end] + set scriptpath [file normalize $scriptname] + if {![file exists $scriptpath]} { + puts stderr "punk script: script file not found: '$scriptpath'" + exit 1 + } + set ::argv0 $scriptpath + set ::argv $scriptargs + set ::argc [llength $scriptargs] + set ::tcl_interactive 0 + info script $scriptpath + if {[catch {uplevel #0 [list source $scriptpath]}]} { + puts stderr $::errorInfo + flush stderr + exit 1 + } + flush stdout + flush stderr + exit 0 + } else { + #stdin form: read the whole of piped/redirected stdin as the script. + #An interactive terminal with no scriptname is a usage error - never + #block on a console read and never fall through to a shell. + if {$stdin_is_terminal} { + puts stderr "punk script: no script file specified and stdin is a terminal" + puts stderr "usage: script ?args...?" + puts stderr " or: | script" + exit 1 + } + set script [read stdin] + set ::argv0 "-" + set ::argv {} + set ::argc 0 + set ::tcl_interactive 0 + if {[catch {uplevel #0 $script} result]} { + puts stderr $::errorInfo + flush stderr + exit 1 + } + #one-shot eval ergonomics for the stdin form only: the script's value + #(its final command's result) is written to stdout when non-empty, so + #one-liners whose commands RETURN their output (e.g 'dev projects.work + #' - displayed by the repl's result display in a shell) emit it + #here too without needing a puts wrapper. File-form scripts keep pure + #script semantics (results discarded). + if {[string length $result]} { + puts stdout $result + } + flush stdout + flush stderr + exit 0 + } +}}