Class: Rubino::CLI::Chat::BangShell

Inherits:
Object
  • Object
show all
Defined in:
lib/rubino/cli/chat/bang_shell.rb

Overview

The ‘!` bang prefix — the human shell escape (Claude Code’s bash mode, also shipped by Gemini CLI, Codex CLI, opencode, and aider’s ‘/run`). `! npm test` at the chat prompt runs the command in the user’s shell IMMEDIATELY, streams its output into the transcript, and then injects command + output into the session as two user-role messages so the model can reference them next turn:

<bash-input>npm test</bash-input>
<bash-stdout>...</bash-stdout><bash-stderr>...</bash-stderr>

That tagged, user-role shape replicates exactly what Claude Code persists for its bash mode (verified against real Claude Code session transcripts). Because the messages live in the session store, they are part of every later turn’s context AND survive resume/branch like any other message.

HUMAN semantics, deliberately distinct from the model’s ‘shell` tool:

* no approval prompt and no hardline floor — the human typed the
  command at their own terminal, the same trust as their normal
  shell (this mirrors Claude Code, which runs `!` commands with no
  gate of any kind);
* `bash -lc` (login shell) so the user's profile PATH applies, and
  no `pipefail` — the model's tool adds pipefail for ITS pipelines
  (#156), but a human's `!` line should behave like their shell;
* no timeout — Ctrl+C terminates the command (SIGTERM, then SIGKILL
  after a grace period) without killing rubino.

Defined Under Namespace

Classes: Result

Constant Summary collapse

PREFIX =
"!"
MAX_CONTEXT_CHARS =

Per-stream cap on what enters the model context — Claude Code’s bash output cap (30k chars). Over the cap we keep the head and the tail with an explicit omission marker, so both the start of a build log and its failing end survive.

30_000
KILL_GRACE_SECONDS =

Grace between SIGTERM and SIGKILL on Ctrl+C, mirroring ShellTool.

1.5
BASH_INPUT_RE =
%r{\A<bash-input>(.*)</bash-input>\z}m
BASH_OUTPUT_RE =
%r{\A<bash-stdout>(.*)</bash-stdout><bash-stderr>(.*)</bash-stderr>\z}m

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.replay(ui, content, at: nil) ⇒ Object

Replays a persisted bang message during –resume/-c history replay: the <bash-input> message renders as the ‘! <command>` line the user originally typed, the <bash-stdout>/<bash-stderr> message as the dim output block — never the raw tags. Returns true when the content was a bang message (caller skips the generic user replay), false otherwise.



75
76
77
78
79
80
81
82
83
84
85
86
87
# File 'lib/rubino/cli/chat/bang_shell.rb', line 75

def self.replay(ui, content, at: nil) # rubocop:disable Naming/PredicateMethod -- a renderer that reports whether it handled the message
  text = content.to_s
  if (m = BASH_INPUT_RE.match(text))
    ui.replay_user_input("! #{m[1]}", at: at)
    true
  elsif (m = BASH_OUTPUT_RE.match(text))
    merged = [m[1], m[2]].reject(&:empty?).join("\n")
    ui.tool_body(merged.empty? ? "(no output)" : merged)
    true
  else
    false
  end
end

Instance Method Details

#handle(input, runner, ui) ⇒ Object

Dispatch entry point, called by the REPL loop before slash dispatch. Returns nil for a non-bang line (fall through to normal dispatch), :handled for a bare ‘!` (usage shown, nothing run/persisted), and :ran after a command actually executed and was injected.



52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
# File 'lib/rubino/cli/chat/bang_shell.rb', line 52

def handle(input, runner, ui)
  return nil unless input.start_with?(PREFIX)

  command = input.delete_prefix(PREFIX).strip
  if command.empty?
    # Bare `!`: error-with-usage (the simpler of the two industry
    # behaviours — Gemini CLI's persistent shell-mode toggle is noted
    # as a follow-up).
    ui.status("usage: ! <command> — runs it in your shell now (no approval); output joins the context")
    return :handled
  end

  result = execute(command)
  render_outcome(result)
  inject!(runner, command, result)
  :ran
end