jsh is an experimental interactive shell that combines familiar Bash syntax
with typed, structured-data pipelines. It is built in Rust and includes a
multiline editor, job control, context-aware completion, session restoration,
local workflows, and optional AI-assisted command generation.
jshimplements a broad and useful subset of Bash, but it is not yet a drop-in replacement for every Bash script. Keep/bin/bashas the interpreter for scripts that require exact Bash behavior.
- Bash-style commands, expansion, functions, arrays, redirections, traps, and foreground/background jobs.
- Structured JSON, YAML, TOML, XML, CSV, and NDJSON pipelines.
- Typed values,
letbindings, closures, typeddeffunctions, and reusable modules throughuse. - Lazy streams (
range,from-ndjson,take) and ordered parallel mapping withpar-each. - Interactive Emacs/Vi editing, fuzzy history search, Git-aware prompts, completions for common developer tools, bookmarks, directory frecency, and parameterized workflows.
- A continuous terminal with semantic command boundaries, allowing compatible terminals to present a Commands timeline without turning output into blocks.
- Optional OpenAI, Anthropic, or local Ollama integration. AI is disabled until explicitly enabled.
Install or update the released binary:
curl -fsSL https://github.com/beamiter/jsh/releases/latest/download/install-jsh.sh | shThe installer downloads the build for the current platform, verifies a detached
minisign signature over the release manifest.json against a pubkey pinned in
the script, checks the archive SHA-256 from that signed manifest (and the
same-origin .sha256 sidecar), and replaces the binary with rename(2), so
shells that are already running keep the version they started with. It installs
next to an existing jsh when it finds one on PATH, and falls back to
~/.local/bin otherwise. Re-running it is how you update. Useful options:
./scripts/install-jsh.sh --check # compare installed against latest
./scripts/install-jsh.sh --channel source # build from source; run from a checkout
# it builds that tree, uncommitted work included
./scripts/install-jsh.sh --git # build the published repository instead
./scripts/install-jsh.sh --source-dir DIR # build a checkout somewhere else
./scripts/install-jsh.sh --stage-dir DIR --target TRIPLE # verify only, install nothing
./scripts/install-jsh.sh --help # bin directory, pinned version, dry runThe previous binary is kept under ~/.local/state/jsh/rollback/ so a bad
release can be undone without a network connection.
The installer identifies binaries by their
jsh --versionbanner, never by name alone, and tells you whenPATHresolvesjshto some other binary instead of this shell.
Build the current checkout:
cargo build --release
./target/release/jsh --versionOr install it into Cargo's binary directory:
cargo install --path .The default build includes HTTP and AI-provider support. To build the shell core without its HTTP client dependency:
cargo build --release --no-default-featuresRun an interactive shell:
jshExecute a command or a script:
jsh -c 'printf "hello %s\n" world'
jsh ./script.jsh one two
printf 'echo from-stdin\n' | jshUse structured data without a chain of text parsers:
jsh -c 'echo '\''[{"name":"Ada","age":36},{"name":"Lin","age":28}]'\'' \
| from-json | where age -gt 30 | select name | to-table'Files are decoded from their extension and can be converted on save:
jsh -c 'open users.json | where {|row| [ $row.active = true ]} | save active.yaml'Typed functions and lazy pipelines extend the shell language:
jsh -c 'def add a:int b:int {|a,b| $a + $b}; add 3 4'
jsh -c 'range 1..1000000 | take 5 | each {|n| $n * $n} | to-json'Discover the available commands from inside jsh:
help
help where
help --record whereCheck whether the surrounding environment can provide startup compatibility, persistent state, trusted helpers, and optional AI without starting a shell or making a network request:
jsh doctor
jsh doctor --json
jsh doctor --strict # exit 1 when any warning is present
jsh doctor --rcfile ./team.jshrc # inspect an explicit startup filejsh [OPTIONS] [SCRIPT [ARG ...]]
jsh [OPTIONS] -c COMMAND [NAME [ARG ...]]
jsh context <list|show|last-failed> [OPTIONS]
jsh doctor [--json] [--strict] [--rcfile FILE]
Important options:
-c, --command COMMANDexecutes a command string. As in Bash, the followingNAMEbecomes$0, and later values become$1,$2, and so on.-s, --stdinreads a program from standard input.-n, --noexec, --checkparses input without executing it.-i, --interactiverequires an interactive terminal editor and cannot be combined with syntax-check mode or an explicit command, script, or stdin.--norcskips the interactive startup file.--rcfile FILEselects an explicit interactive startup file.--session IDrestores and persists a named interactive terminal session.--helpand--versionreport the binary's interface and version.doctor [--json] [--strict] [--rcfile FILE]performs a read-only environment check. JSON reports carryschema_versionandhealthy; strict mode exits 1 on warnings. Persistence checks include the execution journal's fixed lock sidecar. Reports describe credential presence, never values, and report malformed or unsupported Agent capability negotiation without echoing the supplied capability token.
Long options that take one value also accept = syntax, including
--command=..., --rcfile=..., and --session=.... Repeating --rcfile or
--session is an error rather than silently replacing the earlier value.
Startup and session options are accepted for command-line consistency but take
effect only when jsh starts its interactive editor; they do not alter -c,
script, stdin, or syntax-check execution.
CLI errors and syntax errors exit with status 2. Command-not-found and
missing-script failures use 127; commands or scripts that cannot be
executed or read use 126.
An assignment-only command takes the status of its last command substitution,
as in Bash: out=$(false) returns 1. That status also reaches ERR traps and
set -e, so a failed substitution cannot be mistaken for a successful setup
step. Inside the substitution, exit and an explicitly enabled set -e stop
the remaining commands before their output or status can replace the failure.
The parent shell's errexit and ERR trap are reset there by default, matching
Bash; shopt -s inherit_errexit and set -E opt into inheriting them.
Interactive shells import ~/.bashrc by default for compatibility. Use
--rcfile ~/.jshrc for a native jsh startup file, or --norc for a clean
session. Non-interactive -c, script, and stdin execution do not implicitly
load interactive configuration or write interactive history.
History is stored at ~/.jsh_history; named session snapshots live under
~/.jsh/sessions. New files are written with private permissions. History uses
a newline-safe JSONL format while retaining compatibility with the previous
tab-separated format. Session snapshots exclude process-specific variables and
names that look like credentials, tokens, passwords, or secrets.
A few features start a system program: bash for the ~/.bashrc import and for
source of a script jsh's own parser cannot read, git for the prompt, and
notify-send for background-job notifications. jsh looks for these at fixed
absolute paths and never through PATH, which is mutable shell state any
sourced script can rewrite.
On a layout that does not use those paths — Nix, a Homebrew-style prefix, an immutable root — say where the program is:
export JSH_HELPER_GIT=/run/current-system/sw/bin/git
export JSH_HELPER_BASH=/run/current-system/sw/bin/bash
export JSH_HELPER_NOTIFY_SEND=/run/current-system/sw/bin/notify-sendThe variable name is JSH_HELPER_ plus the program name uppercased, with -
becoming _. The path must be absolute and, after symlinks are resolved, must
be an executable file that no third party can replace — neither it nor any
directory above it may be group- or world-writable or owned by another user.
A path that fails those checks disables that integration and says so once; it
does not quietly fall back to a different binary than the one you named.
Typing docker run -it ubuntu bash gives you jsh, without anything being
installed in the container and without configuring anything anywhere:
$ docker run -it ubuntu bash
jsh: entering the container as jsh (read-only mount, nothing installed)
user@f8cb8dfe26b7 / ❯
The shell is a read-only bind mount of a static jsh over a path in the
container's /dev. That is a tmpfs the runtime creates, and one of the few
places in a container that both executes files and stays out of the image's
writable layer, so docker diff on the container is empty afterwards. A
container that is already running cannot be given a mount, so docker exec -it web bash streams the same binary into that same tmpfs instead — still nothing
in the writable layer, and gone when the container stops.
It works on any image, including ones with no libc and no bash: a static binary needs nothing from the image. Alpine, distroless, and Ubuntu all behave the same, and the container starts as fast as it did before.
This only happens for a command that is plainly someone going in to look around, and every rule fails closed:
| rewritten | docker run -it IMAGE bash, docker exec -it NAME sh, docker run -it IMAGE when the image's own default command is a shell — and the same for podman and nerdctl |
| left alone | no -t; a real command (docker run -it img bash -c …); an image with its own ENTRYPOINT; an explicit --entrypoint; a --platform this machine has no binary for; a remote DOCKER_HOST; any flag this shell does not recognise, since one of those could be hiding where the image name is |
command docker … runs exactly what you typed, and
JSH_CONTAINER_SHELL=off turns the whole thing off. The binary jsh injects is
itself, when jsh is a static build; otherwise the artifact
scripts/jsh-remote.sh stages, or whatever JSH_CONTAINER_BINARY names. When
none of those is static, nothing is rewritten — a dynamically linked shell
would fail inside the image with a "no such file or directory" naming a file
that is plainly there.
Once inside, jsh behaves like the shell it replaced: it reads the container's
own ~/.bashrc and keeps its history in the container's home, exactly where
bash keeps .bash_history. Only the binary is a guest.
The same idea works over ssh, where there is no mount to add but there has always been a push:
$ ssh build-box
jsh: bringing jsh to build-box for this session (`command ssh` connects plain)
yj@build-box ~ ❯
An interactive ssh destination — no remote command, only session flags this
shell recognises (-p, -i, -l, -o, -J, …) — is routed through
jsh-remote.sh with the running binary as a candidate. It is lent directly
only when the destination has the same architecture; a different architecture
uses its matching release when one exists and otherwise falls back to shell
integration (or plain ssh when integration is unavailable), so an unsupported
host never blocks login. The destination keeps its own login shell; jsh's
dot-files and a cached copy of the binary land in your remote $HOME, so the
next connection skips the transfer. Anything else — ssh host ls, port
forwarding, -N, -W, a flag this shell does not know — runs exactly as typed,
as does command ssh …, and JSH_SSH_SHELL=off turns it off. Lending needs the
running jsh to be static, which the released Linux binaries now are.
scripts/jsh-remote.sh runs jsh on a machine that does not have jsh installed.
It places a static musl build there, executes it, and takes it away again.
When the jsh running here is itself static — which a Linux install now is —
that binary is the artifact: nothing is fetched from anywhere, and the far
side runs exactly the version that sent it. Releases are the fallback for a
dynamically linked jsh or a different architecture:
./scripts/jsh-remote.sh build-box # ssh
./scripts/jsh-remote.sh --docker my-service # a running containerCompletion, the prompt, history search, workflows, and the OSC 133 marks that drive a terminal's Commands timeline all behave exactly as they do locally, because the shell doing the work really is jsh.
The destination keeps its own shell. Nothing edits .bashrc, .profile,
.zshrc, /etc/profile.d, or the login shell in /etc/passwd; nothing needs
root or a package manager; and nothing is downloaded on the far side — the
release artifact is fetched and verified here by install-jsh.sh --stage-dir
and then pushed over the same connection, so an air-gapped host works too.
Two modes, because "install nothing" means two different things:
--persist (default) |
--incognito |
|
|---|---|---|
remote $HOME |
keeps ~/.jsh_history, ~/.jsh/, and a cached binary under ~/.cache, all private to your account |
never written to |
| history across sessions | kept | discarded |
| repeat connections | skip the transfer | transfer each time |
| use it when | the account is yours | the account is shared |
--incognito points HOME and the XDG variables at a sandbox that is deleted
when the session ends, and sets JSH_REAL_HOME to the account's actual home.
That variable separates two questions that are normally the same one. ~, cd
with no argument, the ~/… abbreviation in the prompt and in completions, and
the startup file all follow JSH_REAL_HOME, because those are paths a person
writes. $HOME keeps pointing at the sandbox, because that is where programs
write — so history, session snapshots, bookmarks, and the frecency database
still cannot escape it. Set it yourself and the same split applies; leave it
unset, or set it to something that is not an existing absolute directory, and
nothing changes.
When a destination cannot execute a file at all — everything writable is
mounted noexec — there is still a middle tier, and it is the default. bash --rcfile reads its argument and never runs it, and noexec refuses execve
while permitting write, so jsh can hand the destination's own bash a
throwaway startup file that emits the same OSC 133 and OSC 7 marks jsh does.
The terminal keeps blocks, cwd tracking and exit codes; jsh's completion and
structured pipelines are not there, because jsh is not there. The destination's
own ~/.bashrc still runs first, and the file is deleted with the sandbox.
--fallback bash asks for the unmodified shell instead, and --fallback fail
refuses to connect.
Useful options: --session ID to forward a terminal session id, --rcfile FILE
to push a startup file, --artifact FILE to ship a binary you built yourself,
and --dry-run to see the plan. --help lists the rest.
Any of the four jterm terminals can use this without changes — run it in a pane, or configure it as the command for a tab.
jsh keeps the terminal as one continuous scrollback while exposing semantic command boundaries to terminal emulators. A compatible terminal can build a chronological Commands timeline, jump to the original prompt, copy a command or its rendered output, and offer rerun actions without imposing a block-based layout.
The integration retains the portable OSC 133 lifecycle: A begins a prompt,
B begins command input, C begins output, and D finishes the command. jsh
adds percent-encoded, size-bounded metadata to C and D: an execution ID,
the exact command when it fits the protocol limit, the working directory, exit
status, and duration. Oversized commands are explicitly marked as truncated
rather than being presented as exact. The execution ID correlates terminal
scrollback with jsh's structured context. It is emitted only when it matches
the journal's exact 1–192-byte ASCII token grammar; a bad direct API input
still emits the portable lifecycle mark but cannot claim a durable correlation.
Commands containing non-structural controls or visually ambiguous formatting
take the non-exact OSC path and are refused by the journal writer. Newline and
tab remain valid multiline shell structure, but hidden formatting never enters
exact OSC or journal metadata.
Percent-encoded newline and tab bytes remain structural command text, so a multiline command has the same identity in OSC metadata and the JSONL execution journal. Repeated semantic metadata aliases are treated as ambiguous by the shared terminal parser rather than resolved last-wins.
Display-only OSC values such as command titles and job notifications keep
ordinary Unicode readable but render controls, non-ASCII spacing, bidi
controls, zero-width text, and other default-ignorables as explicit
percent-encoded bytes. The redundant iTerm2 CurrentDir frame is emitted only
when the exact raw path is bounded and unambiguous; OSC 7 remains the canonical
encoded cwd signal. Cwd identity is never lossy-decoded or truncated: an
unrepresentable, oversized, or visually ambiguous value simply omits the
optional cwd metadata.
Query that context either inside an interactive jsh or from another process:
context list [-n N] [--session ID] [--cwd PATH] [--failed] [--json]
context show EXECUTION_ID [--json]
context last-failed [--session ID] [--cwd PATH] [--json]
context summary [-n N] [--session ID] [--cwd PATH] [--json]
jsh context list [-n N] [--session ID] [--cwd PATH] [--failed] [--json]
jsh context show EXECUTION_ID [--json]
jsh context last-failed [--session ID] [--cwd PATH] [--json]
jsh context summary [-n N] [--session ID] [--cwd PATH] [--json]list defaults to the newest 20 records and accepts a limit from 1 to 2,000.
--cwd matches the recorded start directory exactly; --failed keeps only
nonzero exits. summary returns a bounded Agent-friendly digest of recent and
failed executions without shipping captured output bodies. list reports only
output availability, truncation, and byte-count metadata; show and
last-failed include the captured output itself when available.
Execution context is separate from ~/.jsh_history. Its append-only JSONL
journal defaults to $XDG_STATE_HOME/jsh/executions.jsonl, falling back to
~/.local/state/jsh/executions.jsonl. The jsh state directory is mode 0700;
the journal and its executions.lock sidecar are mode 0600 and coordinated
with flock. Existing journal or lock files that are group/world-writable are
rejected rather than repaired in place; extra read bits on an owner-only file
are tightened back to 0600. At 32 MiB the journal is compacted to the newest
records, with a post-compaction limit of 24 MiB and 2,000 executions. A read
inspects at most 524,288 physical event lines, above the number of conforming v1
events that fit its byte window but finite for malformed short-line streams.
Compaction accepts the source under that same physical-line budget before it
can discard ignored future or unknown events and replace the journal.
It syncs the complete temporary file before rename and the parent directory
after rename. Pre-rename failures preserve the old inode and remove the
temporary; a post-rename parent-sync failure leaves the replacement visible,
reports an unknown commit state, and is safe to compact again without adding
events.
Append checks the prospective line budget before any automatic compaction, so
an exact-limit source is refused byte-for-byte intact instead of being erased
into apparent compliance. Its incremental peer counter charges an unterminated
tail once; a later LF only terminates it, and a compaction rename forces a safe
recount on the replacement inode.
An append after a torn final event charges its recovery newline to the same
reader byte ceiling and refuses an over-budget write before changing the file.
Every successful append data-syncs the journal before returning; creation of a
new journal pathname also syncs its parent directory. A durability-barrier
error happens after the event bytes may already be visible, so its commit state
is explicitly unknown and the writer does not retry the event internally. A
first-byte write failure removes a newly created empty pathname; a partial
write stays visible as a recoverable torn tail and is also commit-unknown.
Before executing an interactive command, jsh reconciles an unknown Start only
when a fresh read finds that exact complete Start as the sole matching identity
and final physical record, then retries only its data and parent barriers. It
returns an internal token containing the complete session/id/sequence Start
identity, so the later Finish cannot be correlated by execution ID alone. An
unknown Finish is likewise never appended twice: jsh retries only its durability
barriers when that token's unique Start and exact sole Finish remain intact and
the Finish is still the final physical record. Torn, duplicated, conflicted,
reset, or followed terminal records remain unbound and produce a
command-redacted, error-kind-only warning.
The OSC 133 C mark carries the same exact session_id, id, seq, and
started_at_ms generation fields so a terminal's asynchronous Output writer
can prove which Start it observed instead of persisting by ID alone. Invalid or
missing session identifiers omit that binding field and therefore cannot form
a durable Output capability.
Individual metadata and captured-output records also have hard size limits.
Exact duplicate finish or output delivery is idempotent; conflicting duplicates
poison only their own lifecycle slot until the next authoritative start.
Compaction keeps that safe unknown state as an additive conflict tombstone
that older v1 readers ignore. Start authority follows physical event order, so
a restart still clears both prior slots when its sequence or wall clock moves
backwards, and compaction cannot rebind those slots to the new generation.
Recursive duplicate-member validation runs before any event can mutate
lifecycle state, including an invalid start barrier. Decoded-key duplicates and
simultaneous canonical/legacy version names are invalid envelopes; compaction
refuses either before creating or renaming a replacement. Unknown event kinds
remain skippable for forward compatibility, but known v1 events reject extra
members so an injected identity or session hint cannot alter lifecycle
correlation. Compaction likewise refuses such a malformed finish/output event,
while still discarding unknown vendor events and future versions of known kinds
after accepting the source budget. A recognized v1 start with a valid execution
id retires that id's prior lifecycle before all remaining fields are decoded
strictly, so an invalid replacement cannot redirect later finish/output events
back to stale command or session data; malformed, future-version, and unknown
events remain non-barriers.
Working directories have no truncation bit in journal v1, so start/finish
events require the exact bounded, unambiguous value instead of recording a
prefix or a lossy UTF-8 replacement as though it were another real directory.
If a damaged or externally produced journal repeats an execution ID, the later
start event replaces the whole earlier lifecycle and takes its new chronological
position; stale status, output, and eviction age cannot attach to the new run.
Bounded retention uses physical Start order rather than untrusted timestamps or
sequence numbers. Compaction emits that same physical order so a later append
cannot resurrect a retired eviction age. list and last-failed keep physical
chronology across clock/sequence resets and identical metadata; timestamps are
display fields only.
The journal can contain sensitive commands, paths, and terminal output. Set
JSH_EXECUTION_JOURNAL=0 to disable disk journaling while retaining OSC
integration for the terminal UI. Set JSH_EXECUTION_JOURNAL_PATH to override
the location; an empty value behaves like an unset override. A non-empty value
must be an absolute, terminal-visible file path no longer than 16 KiB whose
parent is owned by the current user and is not group/world-writable. Shared
namespaces such as /tmp are not valid overrides because the journal and its
fixed executions.lock sidecar share one trust boundary. The journal file
itself cannot use that reserved sidecar name, including case aliases on
case-insensitive filesystems. A non-empty override remains custom even when it
spells the default path; jsh never repairs that explicit namespace in place.
Relative paths and locations containing controls, terminal-invisible text, or
other unsafe bytes are rejected.
AI integration is opt-in. Select a provider when starting jsh:
JSH_AI_PROVIDER=ollama jsh
JSH_AI_PROVIDER=openai jsh
JSH_AI_PROVIDER=anthropic jshFor cloud providers, inject OPENAI_API_KEY or ANTHROPIC_API_KEY beforehand
through your normal secret manager or protected environment configuration; do
not type secrets directly into a recorded command line. JSH_AI_MODEL and
JSH_AI_BASE_URL override provider defaults. Requests include your prompt,
OS, and current-directory path. Cloud requests do not additionally include
recent history or Git status unless
JSH_AI_SHARE_CONTEXT=1 is set. Generated commands are suggestions: inspect
them before execution, especially when they contain destructive operations.
Plain HTTP is accepted only for a syntactic loopback origin, for any provider;
remote endpoints require HTTPS. This supports local OpenAI-compatible and
Anthropic proxies as well as Ollama without weakening cloud transport. A
validated loopback HTTP request also bypasses environment proxy settings so
its credentials cannot leave the loopback hop; HTTPS keeps the user's normal
proxy configuration.
Every editor-AI history window uses jagent's reported request builder. If
older turns are omitted, jsh tells the model that its context is incomplete;
that notice and the trusted system instructions share a strict 64 KiB byte
ceiling. jsh fails the request rather than truncating the system prompt or
silently discarding the notice. Agent mode uses the stricter prepared request
path described below.
The interactive editor exposes three review-first AI actions:
- Type
# describe the command you wantand press Enter to request a command suggestion. The reply stays as ghost text until you explicitly accept it. - Press
Ctrl-Fafter a failed command to request a corrected suggestion. - Press
Alt-Ewith a command on the line to open a read-only explanation. Explanations use a separate model contract and display panel; they can never become command text or be submitted with Enter.
Each request carries an editor generation ID. Leaving the prompt with
Ctrl-C, submitting, editing while a response is pending, or starting another
request invalidates the old ID, so a late provider response cannot replace
newer input.
The agent builtin runs a review-first agent loop on the shared
jagent core (the same state machine used
by the anvil, ember, forge, and frost Shell Agent integrations):
agent find the largest files under target and free some spaceThe model may only propose one command per turn. Every proposal shows a
[y] run [e] edit [i] insert [n] reject [q] quit review prompt; recognized
dangerous commands additionally require typing RUN. [i] moves the command
into your next editor prompt for manual review without executing it and ends
the session. Approved commands execute through the normal jsh parser with
output teed to the terminal, and a bounded sample plus a real exit code is fed
back as the next model turn's observation. If the private snapshot, pipe, or
child boundary cannot start—or the boundary terminates without a normal
status—jsh records an explicit execution failure instead of fabricating exit
code 1. Parent and child coordinate over a separate one-byte readiness pipe:
the child closes it only after snapshot claim/load, state restore, and cwd
setup succeed, before parsing or executing the user command. stdout/stderr can
never forge that signal. Authentication requires one exact marker followed by
EOF. Control, final-cwd, and one-shot nonce descriptors must be three distinct
FIFO identities, not aliases under different fd numbers. The final cwd is a
nonce-bound length-prefixed frame: its prefix is checked incrementally, reads
have fixed per-pass work budgets, and malformed input closes the parent reader
so a writer cannot turn rejection into a shutdown hang. A signal or
status-observation failure after READY is
reported through jagent 0.7's conservative Cancelled compatibility bucket;
it is not claimed to be a normal shell exit. Malformed model replies fail
closed and never become proposals; duplicate JSON object members are rejected
recursively instead of being resolved by decoder order.
Agent requests use jagent 0.7's bound request/response path: the selected
system prompt, provider schema, delivery protocol, secret redaction report,
response decoder, and session ingestion cannot drift apart. The compatible
default is the JSON-in-text protocol over one complete response. A terminal or
other peer can advertise its protocol/delivery support through the canonical,
at-most-256-byte JSH_AGENT_PEER_CAPABILITIES token, for example:
export JSH_AGENT_PEER_CAPABILITIES='jagent-agent/1;protocols=text,native-tools;delivery=complete,streaming'
export JSH_AGENT_PROTOCOL=native-tools
# optional: force streaming when the peer advertises it
# export JSH_AGENT_DELIVERY=streamingIf the peer variable is absent, jsh assumes only the legacy text+complete
path; discovery never silently opts an existing integration into native tools.
An explicit JSH_AGENT_PROTOCOL remains authoritative, but jsh rejects it
unless both the selected provider and peer advertise that protocol for a
negotiable delivery. Negotiation prefers complete when both sides support it
and falls back to streaming for streaming-only peers. Set
JSH_AGENT_DELIVERY=complete|streaming to narrow that preference (fail closed
when the chosen delivery is unavailable). Streaming responses still decode to a
complete AgentResponse before review; partial stream tool-call events never
become executable proposals. Malformed, non-canonical, future-version,
whitespace-bearing, duplicate, or oversized peer tokens are rejected without
being printed. All three built-in providers support native run/say/done
calls. Tool calls remain proposals and pass through exactly the same review
prompt—selecting the native wire format never grants execution permission. Run
jsh doctor to inspect the effective negotiation without contacting the
provider or exposing credentials. Capability-token v2, which can express exact
protocol/delivery pairs rather than a Cartesian product, remains an explicit
peer-aware opt-in; default emission is v1 for rolling-upgrade safety. jsh
exact-pins the revision that supports both and replies in the decoded peer's
schema version.
Before either the ordinary AI client or the independently invokable hidden Agent transport child touches DNS or an HTTP socket, it revalidates the decoded public request's origin, canonical unique headers, JSON-object body, and byte ceilings, including unique body members at every depth. Malformed child envelopes and unknown provider names are rejected without echoing caller-controlled values; duplicate or unknown outer envelope fields are rejected as well. Successful ordinary editor AI replies remain bounded raw bytes until jagent's canonical decoder has rejected recursive duplicate members, so an ambiguous last-wins value can never become a suggestion. Both ordinary and Agent provider responses are capped again after transparent content decoding (currently gzip), so compressed input cannot expand beyond the same encoded-envelope ceiling in memory.
The agent keeps its own working directory: an approved cd carries into the
following turns (shown as cwd → …), while the interactive shell's cwd is
never touched.
JSH_AGENT_MAX_TURNS (default 16) bounds the model-turn budget. The former
JSH_AGENT_AUTO_APPROVE_READONLY switch is retired: command text cannot prove
what aliases, functions, Git helpers, or tool flags will actually execute, nor
whether a seemingly harmless read would disclose a secret to the model. If the
old switch is still set, jsh warns and continues to require approval for every
proposal. Git branch/dirty metadata is attached only under the same
JSH_AI_SHARE_CONTEXT rules as other cloud context. Agent commands run in a
fresh one-shot jsh child initialized from a private, size-bounded snapshot that
is atomically claimed by only one child process. Aliases, functions, variables,
and shell options are therefore available without letting export or other
mutations change the interactive shell's state (cd persists only within the
agent session). AI worker queues hold at most one bounded request and response;
a detached descendant inheriting the output pipe cannot hold the Agent turn
open after the direct child exits. Ctrl-C also releases the Agent promptly
while a provider connection is stalled: cancellation kills and reaps the
transport process group, so there is no abandoned prior request or
single-flight shutdown gate blocking the next one.
Local context queries never send journal data over the network. Local Ollama
may use the most recent failed execution's captured terminal output for command
repair. Cloud providers receive execution output only when
JSH_AI_SHARE_CONTEXT=1 explicitly opts in; otherwise AI repair falls back to
the command and exit status. Review journal contents before enabling cloud
context sharing because terminal output can contain source code, paths, tokens,
or other secrets.
Tab completes by what the word actually names, not by filename alone.
- Commands and syntax: builtins, functions, aliases,
PATHentries and shell keywords; the word afterdo,then,else,|,&&,`and$(is a command position; redirection targets are files, and2>&takes a descriptor. - Repository state: branches, tags, remotes, stashes, recent commits,
modified files (
git add), worktrees, andgit configkeys.git branch -doffers only branches Git would let you delete. - The machine's own state: users and groups, jobs and signals, processes
for
kill, systemd units, Docker containers and images, ssh hosts from~/.ssh/config(followingInclude) andknown_hosts, kubeconfig contexts and namespaces,nvm/pyenv/rbenvversions, and a project's virtual environment. - Project files: npm scripts and dependencies,
maketargets, Cargo binaries, examples, features and packages, and Compose services. - Typed pipelines:
from-json data.json | where <TAB>offers the fields that file actually has, with the type each holds, across JSON, NDJSON, YAML, TOML and CSV. Adeffunction's parameters complete by their declared type. - Flags and their values: descriptions for everyday tools, and fixed value
sets where they exist —
curl -X,git log --pretty=,find -type,journalctl -p,systemctl --state=,kubectl -o,testoperators,chmodmodes,mansections.
Typing that is close but not exact still finds its target: paths fall back to case-insensitive matching, and everything else to fuzzy subsequence matching, only when nothing matches exactly — so precise typing keeps its precise order. The menu underlines the characters that matched. Accepting a candidate is remembered per command, so the next list leads with what you chose before.
Everything is local. Completion never runs a command to find out what to
offer, and never makes a network request: Docker, Git and systemd are probed
with fixed, trusted binaries under a timeout and an output cap, once per
command line; everything else is read from files. Kubernetes resource names
and remote scp paths are deliberately absent for exactly this reason.
complete and compgen accept the bash spellings, including -A <action>,
which resolves to the sources above, and -F <function>, which is called with
COMP_WORDS, COMP_CWORD, COMP_LINE and $1/$2/$3 as bash sets them.
Both reach one implementation of each action, so a script and a keystroke
cannot disagree about what a user or a service is.
Specifications ship for git, docker, cargo, kubectl, npm, gh,
systemctl, tmux, terraform and apt, and more can be placed in
~/.jsh/completions/. A spec's argument may name a generator to reach any
of the dynamic sources above, and a spec may wraps another command when it
is the same interface under a different name — podman ships as nothing but
a link to docker.
debug-completion '<command line>' shows what completion would answer at the
end of that line, and which source answered: a list says what is on offer but
never why, and why is the first question when an answer looks wrong.
Press Ctrl-G to search the local workflow registry. Choosing a workflow walks
through its parameters, showing descriptions, defaults, and suggestions, then
places the rendered command on the line for review; it never executes the
command. Esc cancels and restores the line that was present before opening
the workflow picker.
Workflow files live in ~/.jsh/workflows/. Each .json file can contain one
workflow or an array. A minimal definition is:
{
"name": "serve",
"description": "Serve a directory over HTTP",
"command": "python3 -m http.server {{port}} --directory {{path}}",
"parameters": [
{"name": "port", "default": "8000", "suggestions": ["8000", "8080"]},
{"name": "path", "description": "Directory to serve", "default": "."}
],
"tags": ["python", "http"]
}Only names declared in parameters are substituted. Other moustache syntax is
left untouched, so commands may safely contain Docker/Go/Helm expressions such
as {{.Id}}.
Use workflow list (or wf list) to inspect names and
workflow show NAME to inspect a template without entering the picker.
The main verification commands are:
cargo fmt --all -- --check
cargo clippy --all-targets --all-features --locked -- -D warnings
cargo test --all-features --locked --no-fail-fast
cargo test --no-default-features --locked --no-fail-fast
cargo doc --all-features --locked --no-deps
cargo build --release --all-features --locked
shellcheck -s sh scripts/install-jsh.sh scripts/jsh-remote.sh
./scripts/test-install-jsh.sh
./scripts/test-jsh-remote.shBenchmarks are available through cargo bench and the comparison scripts
bench.sh and bench_nu.sh.
- Startup-file import can transfer environment variables, aliases, and selected shell options, but not every arbitrary Bash function or interactive plugin.
- Some advanced Bash options and edge cases remain incomplete. Prefer an
explicit Bash shebang for production scripts that depend on exact Bash
parsing or
set -ecorner cases. shopt -s lastpiperuns the final pipeline stage in the current shell when job control (monitor) is off. Interactive shells keepmonitoron by default, matching Bash.fg/bg/wait/disownaccept Bash job specs%N,%%,%+, and%-.DEBUGandRETURNtraps are accepted for compatibility but are not fired.- Structured pipeline commands are jsh extensions and are not portable to Bash.
- HTTP and AI features are available only in builds with the
aiCargo feature.
Please include the smallest reproducing command, expected status, actual status, and platform details when reporting a compatibility issue.