<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
importsyntax is not available. Userequire()for npm packages (fetched from esm.sh CDN).- The
fsglobal is a VFS bridge, not Node'sfsmodule. Method names and signatures differ. All methods are async. exec()runs commands through the WASM bash interpreter, notchild_process. Useexec.spawn(argv)to bypass shell parsing.fetch()is available (routed through SLICC's proxy).- Node built-in modules (
http,crypto,net, etc.) are not available. Thehttpglobal in jsh is the SLICC API client builder, not Node's http module. process.stdinis fully buffered before the script runs — no streaming.
DISCOVERY DETAILS
- Any
.jshfile anywhere on the VFS is discoverable. /workspace/skills/is scanned first — skill scripts take priority.- Built-in command names shadow
.jshscripts with the same name. - First file found for a given basename wins; duplicates are ignored.
which <command>shows the VFS path for discovered.jshcommands.
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.
await fs.readFile(path)— read file as stringawait fs.readFileBinary(path)— read file as Uint8Arrayawait fs.writeFile(path, content)— write string to fileawait fs.writeFileBinary(path, bytes)— write Uint8Array to fileawait fs.readDir(path)— list directory entriesawait fs.exists(path)— check if path existsawait fs.stat(path)— returns{ isDirectory, isFile, size }await fs.mkdir(path)— create directory (recursive)await fs.rm(path)— remove file or directory (recursive)await fs.fetchToFile(url, path)— download URL to VFS, returns byte count
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).