<title>

jsh - JavaScript shell scripts (.jsh files)

OVERVIEW

.jsh files are JavaScript shell scripts that are auto-discovered as commands anywhere on the VFS. Place a file named greet.jsh on the filesystem and it becomes the shell command greet. The command name is the filename without the .jsh extension.

Discovered scripts appear under "User scripts (.jsh)" in the commands output. Discovery scans /workspace/skills/ first (giving skill-shipped scripts priority), then the rest of the VFS. The first .jsh file found for a given basename wins.

GLOBALS

Scripts run in an async wrapper with these globals available:

Core globals

<dl>

Runtime extensions

The following globals are available in all .jsh scripts. They replace cross-skill boilerplate — prefer them over hand-rolled equivalents.

<dl>

TOP-LEVEL AWAIT

Script bodies are wrapped in an AsyncFunction, so top-level await works directly. Use it for all async operations.

NEVER use .then() — the function body exits before promise chains resolve, causing callbacks to silently produce no output. Always use await instead.

// WRONG — silent failure, no output
fs.readFile('/workspace/data.txt').then(content => {
  console.log(content);
});

// CORRECT — always await
const content = await fs.readFile('/workspace/data.txt');
console.log(content);

STDIN

Piped input is buffered fully before the script runs — there is no streaming. process.stdin.read() drains the buffer; subsequent calls return null. The async iterator shares consumed state with read().

// echo "a,b,c" | parse-csv
const data = process.stdin.read();   // 'a,b,c\n'
const again = process.stdin.read();  // null — buffer drained

// Or iterate:
let total = '';
for await (const chunk of process.stdin) total += chunk;

// Non-consuming view:
const peek = String(process.stdin);

If no input is piped, read() returns '' on first call, then null. process.stdin.isTTY is always false.

EXAMPLE

A script at /workspace/skills/my-skill/wordcount.jsh becomes the command wordcount:

// wordcount.jsh — count words in a file
const { positional, flags } = process.argv.parseFlags();
if (!positional[0]) cli.die('usage: wordcount <file>');

const text = await fs.readFile(positional[0]);
const words = text.trim().split(/\s+/).length;
const lines = text.split('\n').length;

if (flags.json) {
  cli.out({ words, lines, file: positional[0] });
} else {
  console.log(fmt.table([
    ['Words', String(words)],
    ['Lines', String(lines)],
  ]));
}

EXAMPLE — API CLIENT

// github-stars.jsh — list stargazers for a repo
const { positional } = process.argv.parseFlags();
const repo = positional[0];
if (!repo) cli.die('usage: github-stars <owner/repo>');

const gh = http.client({
  baseUrl: 'https://api.github.com',
  token: (req) => skill.token('github'),
  retry: { on: [429], maxAttempts: 3 },
});

const stars = await gh.get(`/repos/${repo}/stargazers`);
console.log(c.bold(`${repo}`) + ` — ${c.yellow(stars.length + ' stars')}`);

EXAMPLE — BROWSER AUTOMATION

// check-slack.jsh — read the current Slack workspace title
const tab = await browser.findTab({ domain: 'slack.com' });
if (!tab) cli.die('open slack.com first');

const title = await browser.eval(tab, () => document.title);
console.log(c.green('Slack:'), title);

// Fetch using the page's session cookies
const resp = await browser.fetch(tab, '/api/auth.test', { method: 'POST' });
if (resp.ok) console.log('Team:', resp.body.team);

SKILL INTEGRATION

Skills can ship .jsh scripts alongside their SKILL.md. These scripts are discovered automatically and become available as shell commands when the skill is installed. This is the primary way skills expose new command-line tools to the agent.

Use skill.dir, skill.refs, and skill.config() to access skill-relative paths and configuration without manual dirname math.

DUAL-MODE EXECUTION

.jsh scripts work in both CLI/standalone mode and Chrome extension mode. In CLI mode, scripts execute via AsyncFunction constructor. In extension mode, execution routes through a CSP-exempt sandbox iframe. The API surface is identical in both modes — scripts do not need to handle mode differences.

DIFFERENCES FROM NODE.JS

DISCOVERY DETAILS

SEE ALSO

man skill — skill system that ships .jsh scripts.
man commands — full list of available shell commands.
man node — inline JavaScript execution via node -e.
man playwright-cli — browser automation (lower-level than browser.*).

jsh - JavaScript shell scripts process

process.argv — argument array (["node", scriptPath, ...userArgs]).
process.argv.parseFlags() — parse flags into { positional, flags, subcommand, passthrough }.
process.env — environment variables (snapshot).
process.exit(code) — exit the script with a status code.
process.stdout.write(str) — write to stdout.
process.stderr.write(str) — write to stderr.
process.cwd() — current working directory.
process.stdin.read() — drain piped stdin buffer (null after EOF).
process.stdin[Symbol.asyncIterator]() — iterate over buffered stdin.
String(process.stdin) — non-consuming view of stdin buffer.

console

console.log(), console.info() write to stdout.
console.error(), console.warn() write to stderr.

fs

VFS bridge object. All methods are async — always await them.

Relative paths are resolved against the current working directory.

exec(command)

Run a shell command via the WASM bash interpreter. Returns { stdout, stderr, exitCode }. Always await it.

exec.spawn(argv) — bypass shell parsing; pass an array of strings. Eliminates quoting bugs when constructing commands programmatically.

fetch

Standard fetch routed through SLICC's proxied transport (cookies + CORS + secret masking handled).

require(specifier)

Pull npm packages from esm.sh CDN. Version-pinnable: require('lodash@4'). Cached per session. Returns the module's default export or namespace object.

const _ = require('lodash');
const { marked } = require('marked');
const dayjs = require('dayjs@1');

process.argv.parseFlags()

Parse --flag=val, --flag val, -x (short boolean), positional args, and -- passthrough. Returns:

{
  positional: string[],          // non-flag arguments
  flags: Record<string, string | boolean | string[]>,
  subcommand: string | null,     // first positional if it looks like a bareword
  passthrough: string[]          // everything after --
}

Starts parsing at argv[2] (skips "node" and script path). Repeated flags promote to array.

cli

cli.die(msg, exitCode?) — write error to stderr, exit 1.
cli.die(msg, { prefix?, exitCode? }) — custom prefix: cli.die('not found', { prefix: 'gh' }) outputs "gh: not found".
cli.out(value) — pretty-print JSON or string to stdout.
cli.warn(msg) — write warning to stderr.
cli.help(text) — print help text to stdout, exit 0.

c (colors)

ANSI color helpers, auto-disabled on non-TTY or NO_COLOR.
c.green(s), c.red(s), c.yellow(s), c.gray(s), c.bold(s), c.cyan(s), c.dim(s).
c.enabled — boolean indicating whether colors are active.

time

Duration and date helpers. Units: ms s m h d w M y (m = minutes, M = months).
time.parseDuration(spec) — returns milliseconds.
time.ago(spec, from?) — Date N ago.
time.range(spec, from?){ start, end }.
time.future(spec, from?){ start, end }.
time.gmailDate(spec, from?) — "YYYY/MM/DD" string.

fmt

ANSI-aware text formatting.
fmt.trunc(s, n) — truncate with ellipsis.
fmt.col(s, width) — pad/truncate to fixed column width.
fmt.table(rows, widths?) — format rows into aligned table string.
fmt.date(value, style?) — format date. Styles: 'short' (YYYY-MM-DD), 'iso' (ISO 8601), 'human' (e.g. "3 days ago"), 'locale' (e.g. "May 29, 2026").

pool

Bounded concurrency runner.
await pool(n, items, fn) — run fn on each item with at most n concurrent promises. Results returned in input order.

skill

Script-relative paths, config, and token access. Computed from argv[1].
skill.dir — directory containing the running script.
skill.refs<dir>/references.
skill.assets<dir>/assets.
await skill.config() — read parsed JSON from <dir>/.config (null if missing).
await skill.config(updates) — shallow-merge + write, returns merged object.
await skill.token(providerId) — get OAuth token for a provider (shells out to oauth-token).

http

API client builder with retry, token, and timeout support.

const api = http.client({
  baseUrl: 'https://api.example.com',
  token: (req) => skill.token('provider'),
  headers: { Accept: 'application/json' },
  retry: { on: [429, 503], maxAttempts: 4 },
  timeoutMs: 30000,
});

const data = await api.get('/endpoint');
await api.post('/endpoint', { body: { key: 'val' } });
await api.put('/endpoint', { params: { q: 'search' }, body: obj });
await api.delete('/endpoint/123');

Non-2xx responses throw HttpError with { status, statusText, url, body }.
token(req?) receives { method, path, url } context; lazy per-request.
retry uses exponential backoff but respects Retry-After headers.
timeoutMs — per-attempt timeout. Pass { raw: true } in opts to get { body, headers, status }.

browser

Page-context CDP bridge. Replaces playwright-cli shell-outs for tab discovery and in-page evaluation.

await browser.findTab({ domain?, urlMatch? }) — find an open tab.
await browser.ensureTab(url, { matchUrl? }) — open if missing.
await browser.eval(tab, fn) — evaluate sync expression in page.
await browser.evalAsync(tab, fn) — evaluate async function in page.
await browser.cookie(tab, name) — read a cookie.
await browser.localStorage(tab, key) — read localStorage value.

await browser.fetch(tab, url, opts?) — page-context fetch (session cookies automatic). Returns { ok, status, headers, body }.

browser.websocket — declarative WebSocket observer:

const sub = await browser.websocket
  .on(tab, { urlMatch: /pattern/ })
  .filter({ parseAs: 'json', where: { type: 'message' } })
  .forward({ sink: 'webhook', webhookId: 'my-hook' });

await sub.update({ filter: { where: { channel: 'new' } } });
await sub.close();
await browser.websocket.list();

Sinks: 'webhook', 'scoop', 'vfs', 'log' (closed enum — no arbitrary URLs).