8.4 KiB
G-015 Punk executable script subcommand: reliable non-interactive piped/script execution
Status: achieved 2026-07-07
Scope: src/vfs/_config/punk_main.tcl, src/lib/app-punkshell/punkshell.tcl (script path or a leaner dedicated app package)
Goal: <punkexe> script [<scriptname>] [<args>...] 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.
Acceptance: piping commands to <punkexe> script runs them and terminates at stdin EOF with no trailing exit required, exit code 0 on success; a failing piped command terminates the process with a nonzero exit code and the error on stderr, never landing in an interactive shell regardless of console availability or PUNK_PIPE_EOF; <punkexe> script <file> [<args>...] executes the file with conventional ::argv0/::argv and propagates its error status the same way; the script path installs none of the shell subcommand's shellfilter stacks/transforms and the launch plumbing itself emits nothing on stdout/stderr (the current stub's stderr diagnostics removed) so exec-style callers see only the script's own output; the motivating example works with no package require boilerplate: piping dev projects.work *<name>* to <punkexe> script emits the matching-project table and exits 0, because the script interp carries the default punk shell module/alias environment.
Context
Agents (and exec-style callers generally) currently have no reliable way to run a one-shot command against a punk executable. The working pattern is to pipe repl input into the default shell path, e.g. (powershell):
'dev projects.work *tomlish*;exit' | punk902z
This is fragile in two ways:
- The trailing
exitis load-bearing: without it, stdin EOF triggers the app-punkshell eof handling, whose default heuristic policy attempts to reopen the console (CONIN$//dev/tty) and drop into an interactive repl. When a console exists but no human is watching (typical agent/CI harness), that is a permanent hang.PUNK_PIPE_EOF=exitmitigates, but callers must know to set it. - If any command before the
exiterrors, the repl keeps consuming input and theexitline may never run as intended - the session can still land interactive, and the process exit code does not reflect the failure.
Meanwhile the script subcommand in the punk_main.tcl dispatch
(src/vfs/_config/punk_main.tcl ~1163) is an incomplete stub: it prints stderr
diagnostics ("main.tcl launching script with args: ...") and sets
::tcl_interactive, but never actually executes anything. Note also the dispatch
default (~1090): any first argument that is not a recognised mode/subcommand is
reclassified as script, so completing this path also completes the bare
<punkexe> <somefile> form.
The shell subcommand path (app-punkshell) wraps the session in shellfilter
channel stacks (punkshellout/punkshellerr with file/syslog logging targets,
plus ansiwrap on stderr in places). Those transforms exist for interactive-shell
ergonomics and logging; for a one-shot script call they add overhead, alter
output, and risk stderr chatter - which matters because Tcl exec treats any
stderr output as command failure by default (see the comment block in
punk_main.tcl's punk subcommand case).
Approach
-
Decided 2026-07-07: a leaner dedicated app package (not a mode of app-punkshell). Rationale: the acceptance is defined by absence (no shellfilter stacks, no logging side effects, no interactive fallback) which a package that never loads that machinery guarantees by construction; app-punkshell's load-time behaviour (shellfilter stacks with syslog/file targets writing punkshell_out.log/punkshell_err.log into the caller's cwd, logging threads) is wrong for one-shot calls; and isolation caps the blast radius of shell-path machinery (the 2026-07-06 silent-stdout failure lived in exactly the code reuse would couple to). Duplication mitigation: the substantive shared piece - the definition of the default punk shell module/alias environment - is factored into a shared module-level definition (punk::aliascore as the seed) consumed by both app packages; launcher control flow is deliberately not shared (share definitions via modules, not control flow). This package shape also previews the G-031 registration model (subcommand -> small handler package), as app-tomlish demonstrated.
-
<punkexe> script <file> [<args>...]- execute the file with conventional ::argv0/::argv semantics, exit with its status. -
... | <punkexe> script- read the whole of stdin as the script when no scriptname is supplied and stdin is piped/redirected; terminate at EOF, never reopening a console. The scriptname form must still work with piped stdin present (the script may itself want to read stdin) - "with or without a trailing scriptname". -
No shellfilter stacks/transforms on the script path; plain channels.
-
No launcher output of its own on stdout or stderr (remove the stub diagnostics); the script's own output is the only output.
-
Errors: report on stderr, exit nonzero. Success: exit 0 (or the script's own explicit exit code). No interactive fallback on this subcommand under any eof/error condition - PUNK_PIPE_EOF and console availability are irrelevant here by design.
-
The script interp loads the basic punk modules and aliases that are available by default in an interactive punk shell, so commands like
dev projects.workwork in a piped one-liner with nopackage requireboilerplate. This is deliberately not the leanest environment for basic scripts - fast/bare script execution is the future province of an expandedtclshsubcommand, out of scope here. The exact recommended one-liner gets documented under G-017.
Alternatives considered
- Hardening the shell/repl pipe path instead (PUNK_PIPE_EOF, error-aborts-input)
- insufficient alone: the shell path's channel transforms and repl semantics are wrong for exec-style callers even when eof/error policy is fixed; a dedicated clean path is simpler than making the interactive path dual-purpose.
- Requiring agents to set PUNK_PIPE_EOF=exit and keep the trailing
exit- rejected as the durable answer: it leaves exit codes meaningless on mid-script errors and depends on every caller knowing the incantation. - A bare interp (no punk modules/aliases preloaded) for the script path -
rejected for this subcommand: the point is that shell-default commands
(
dev projects.worketc.) work as piped one-liners; lean bare-script execution belongs to a later expansion of thetclshsubcommand.
Notes
- Achieved-status detail (restored 2026-07-11; this text originally rode the
Status: line as a parenthetical and was dropped when the 2026-07-11 two-tier
index restructure normalised detail-file Status lines to the bare index form):
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. - Result-shape observation (2026-07-07):
shellrun::runx -tclreturns a dict keyed {result stdout stderr} with NO exitcode key, unlike the exec-mode result (exitinfo with exitcode) - callers doingdict get $result exitcodeerror. Possibly by design for in-process eval, but the script-path work here should document (or normalise) the result shapes agents will consume. - The eof policy machinery (PUNK_PIPE_EOF exit/interactive/heuristic) in
app-punkshell (~punkshell.tcl 466-507) stays as-is for the
shellsubcommand; this goal does not change interactive-shell behaviour. - Related later goal: G-016 extends what the motivating
projects.workcall can find; G-017 documents the agent-facing invocation pattern once this lands.