<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. console, process, require, module, and fetch are available directly as globals — there are no other bare globals. Everything that used to be a bespoke global (skill, cli, fmt, c, http, exec, time, pool, browser, and the device-bridge modules) has moved behind an explicit require('sliccy:<name>') call. Nothing is injected implicitly; import what you need.
Core globals
console, process, require, module, fetch. require() resolves real npm packages (fetched from esm.sh CDN, see below) as well as the sliccy: scheme. The fs global bridge is gone — use require('fs') instead (see DIFFERENCES FROM NODE.JS).
sliccy: capability modules
Pull in runtime capabilities explicitly with require('sliccy:<name>'). The full set of known names: exec, skill, http, browser, usb, serial, hid, cli, color, time, fmt, pool. Requiring an unknown name throws with the list of known names; require('sliccy:') (empty name) also throws. sliccy: requires never consult the module cache or fall through to real node builtins — each call resolves fresh against the fixed registry.
const exec = require('sliccy:exec'); // callable directly: await exec('ls -la')
const skill = require('sliccy:skill'); // skill.dir, skill.token(), ...
const http = require('sliccy:http'); // http.client({...})
const cli = require('sliccy:cli'); // cli.die(), cli.out(), ...
const color = require('sliccy:color'); // color.green(), color.enabled, ... (was `c`)
const fmt = require('sliccy:fmt'); // fmt.table(), fmt.trunc(), ...
const time = require('sliccy:time'); // time.parseDuration(), ...
const pool = require('sliccy:pool'); // await pool(n, items, fn)
const browser = require('sliccy:browser'); // browser.findTab(), ...
const usb = require('sliccy:usb'); // usb.list(), usb.request()
const serial = require('sliccy:serial'); // serial.list(), serial.request()
const hid = require('sliccy:hid'); // hid.list(), hid.request()
Note the rename: the old bare c color-helper global is now require('sliccy:color'), not require('sliccy:c').
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.
const fs = require('fs');
// WRONG — silent failure, no output
fs.promises.readFile('/workspace/data.txt', 'utf8').then(content => {
console.log(content);
});
// CORRECT — always await
const content = await fs.promises.readFile('/workspace/data.txt', 'utf8');
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 fs = require('fs');
const cli = require('sliccy:cli');
const fmt = require('sliccy:fmt');
// process.argv is a plain array: ['node', scriptPath, ...userArgs].
// There is no built-in flag parser — parse --flags yourself.
const args = process.argv.slice(2);
const flags = {};
const positional = [];
for (const a of args) {
if (a.startsWith('--')) {
const [key, val] = a.slice(2).split('=');
flags[key] = val ?? true;
} else {
positional.push(a);
}
}
if (!positional[0]) cli.die('usage: wordcount <file>');
const text = await fs.promises.readFile(positional[0], 'utf8');
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 cli = require('sliccy:cli');
const skill = require('sliccy:skill');
const http = require('sliccy:http');
const color = require('sliccy:color');
const repo = process.argv[2];
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(color.bold(`${repo}`) + ` — ${color.yellow(stars.length + ' stars')}`);
Simpler alternative that avoids sliccy:http/sliccy:skill entirely — process.env.GITHUB_TOKEN is already populated with a live GitHub OAuth token, so plain fetch() works too:
const resp = await fetch(`https://api.github.com/repos/${repo}/stargazers`, {
headers: { Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, Accept: 'application/vnd.github+json' },
});
const stars = await resp.json();
EXAMPLE — BROWSER AUTOMATION
// check-slack.jsh — read the current Slack workspace title
const cli = require('sliccy:cli');
const color = require('sliccy:color');
const browser = require('sliccy:browser');
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(color.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);
DEVELOPING WITH NPM DEPENDENCIES
A .jsh script can depend on real npm packages, resolved through require() the same way node -e does (see man node). The workflow: install with ipk (see man ipk), require() the package directly while iterating, then bundle with esbuild --bundle (see man esbuild) once the script is ready to move or ship somewhere without its own node_modules — e.g. into a skill's scripts/ directory.
Worked example — a tiny args-parser demo (verified end-to-end):
$ mkdir -p /workspace/jsh-demo && cd /workspace/jsh-demo
$ ipk add mri
ipk: installed mri@1.2.0 -> /workspace/jsh-demo/node_modules/mri
// greet-src.jsh — source form, requires a sibling node_modules to run
const mri = require('mri');
const argv = mri(process.argv.slice(2), {
boolean: ['shout'],
alias: { n: 'name', s: 'shout' },
default: { name: 'world' },
});
let msg = `Hello, ${argv.name}!`;
if (argv.shout) msg = msg.toUpperCase();
console.log(msg);
At this point greet-src is already a working shell command (per DISCOVERY DETAILS below) as long as it stays next to its node_modules:
$ greet-src --name=Lars --shout
HELLO, LARS!
$ greet-src -n Lars
Hello, Lars!
To make it portable — no dependency on a sibling node_modules — bundle it:
$ esbuild --bundle --format=esm --outfile=greet-bundled.jsh greet-src.jsh
$ cp greet-bundled.jsh /anywhere/else/ && cd /anywhere/else
$ greet-bundled --name=Isolated --shout
HELLO, ISOLATED!
The bundled file runs correctly with zero node_modules present — mri's source is inlined directly into the .jsh file. This is the form to ship inside a skill's scripts/ directory when the skill depends on an npm package: bundle once during development, commit the bundled .jsh, and skip requiring consumers to run ipk themselves.
Optional lint/format pass before shipping (see man biome for why the --stdin-file-path form is needed for .jsh content):
$ ipk add @biomejs/wasm-web@2.5.1 @biomejs/js-api@6.0.0 esbuild-wasm@0.28.1
$ cat greet-src.jsh | biome format --stdin-file-path=greet-src.js
See man ipk, man esbuild, man tsc, and man biome for the full reference on each build tool.
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) as well as thesliccy:capability-module scheme (see GLOBALS).- There is no bespoke
fsglobal anymore — callrequire('fs'). It is a VFS bridge, not Node's realfsmodule: method names mostly match Node's async API, but there are no sync methods (fs.readFileSyncetc. do not exist) and the method set is fixed — seerequire('fs')below. require('sliccy:exec')runs commands through the WASM bash interpreter, notchild_process(which is unavailable — attemptingrequire('child_process')throws). The returned value is directly callable (await exec('ls -la')) and also exposesexec.spawn(argv)to bypass shell parsing.fetch()is available as a bare global (routed through SLICC's proxy) — no import needed.- Node built-in modules (
crypto,net, etc.) are mostly not available;child_processis explicitly blocked.require('sliccy:http')is the SLICC API client builder, unrelated to Node'shttpmodule. process.stdinis fully buffered before the script runs — no streaming.- There is no
process.argv.parseFlags()helper —process.argvis a plain array (['node', scriptPath, ...userArgs]) and flag parsing is left to the script.
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.*).man ipk — install npm dependencies for require().man esbuild — bundle a .jsh script and its dependencies into one portable file.man tsc, man biome — TypeScript transpile and lint/format for .jsh development.
jsh - JavaScript shell scripts process
process.argv — argument array (["node", scriptPath, ...userArgs]), a plain array like real Node — no built-in flag parser attached; parse --flags yourself (see EXAMPLE above).process.env — environment variables (snapshot). Notably process.env.GITHUB_TOKEN is pre-populated with a live GitHub OAuth token when available.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.
require('fs')
VFS bridge module, not Node's real fs. All methods are async, both on the module itself and under .promises (both expose the same method set — there is no sync API). Always await them.
const fs = require('fs');
await fs.promises.readFile(path, 'utf8')— read file as stringawait fs.promises.readFileBinary(path)— read file as Uint8Arrayawait fs.promises.writeFile(path, content)— write string to fileawait fs.promises.writeFileBinary(path, bytes)— write Uint8Array to fileawait fs.promises.readdir(path)/readDir(path)— list directory entriesawait fs.promises.exists(path)— check if path existsawait fs.promises.stat(path)— returns{ isDirectory, isFile, size }await fs.promises.mkdir(path)— create directory (recursive)await fs.promises.rm(path)— remove file or directory (recursive)await fs.promises.fetchToFile(url, path)— download URL to VFS, returns byte count- also:
appendFile,cp,mkdtemp,rename,access,unlink,rmdir,copyFile
Relative paths are resolved against the current working directory. There is no fs.readFileSync or any other sync variant.
fetch
Standard fetch routed through SLICC's proxied transport (cookies + CORS + secret masking handled). Available as a bare global, no require() needed.
require(specifier)
Two resolution paths:
- Real npm packages, pulled from esm.sh CDN. Version-pinnable:
require('lodash@4'). Cached per session. Returns the module's default export or namespace object. sliccy:<name>capability modules (see below) — resolved by a separate fixed registry, never cached or fallen through to npm/node builtins.
const _ = require('lodash');
const { marked } = require('marked');
const dayjs = require('dayjs@1');
const fs = require('fs');
const path = require('path');
const os = require('os');
require('child_process') throws — it is not available in the browser environment. Use require('sliccy:exec') instead.
sliccy: capability modules
Explicit imports, never bare globals: require('sliccy:exec'), require('sliccy:skill'), require('sliccy:http'), require('sliccy:browser'), require('sliccy:usb'), require('sliccy:serial'), require('sliccy:hid'), require('sliccy:cli'), require('sliccy:color'), require('sliccy:time'), require('sliccy:fmt'), require('sliccy:pool'). Requiring any other name throws listing the known names; requiring the empty name (sliccy:) also throws.
require('sliccy:exec')
Run a shell command via the WASM bash interpreter. The returned value is directly callable and returns { stdout, stderr, exitCode }. Always await it.
const exec = require('sliccy:exec');
const r = await exec('ls -la');
console.log(r.stdout);
exec.spawn(argv) — bypass shell parsing; pass an array of strings. Eliminates quoting bugs when constructing commands programmatically. (exec.exec(...) also exists as an alias for calling exec(...) directly.)
require('sliccy: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.
require('sliccy:color') (formerly the bare c global)
ANSI color helpers, auto-disabled on non-TTY or NO_COLOR.color.green(s), color.red(s), color.yellow(s), color.gray(s), color.bold(s), color.cyan(s), color.dim(s).color.enabled — boolean indicating whether colors are active.
require('sliccy: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.
require('sliccy: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").
require('sliccy:pool')
Bounded concurrency runner — the module itself is the callable function.await pool(n, items, fn) — run fn on each item with at most n concurrent promises. Results returned in input order.
require('sliccy: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. For GitHub specifically, reading process.env.GITHUB_TOKEN directly is simpler and avoids the import.
require('sliccy:http')
API client builder with retry, token, and timeout support.
const http = require('sliccy:http');
const skill = require('sliccy:skill');
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 }. For simple one-off calls, a bare global fetch() with a manually-set Authorization header is often less ceremony than building an http.client.
require('sliccy: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 browser = require('sliccy:browser');
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).
require('sliccy:usb') / require('sliccy:serial') / require('sliccy:hid')
Device-bridge modules, each exposing .list() and .request() for browser WebUSB/WebSerial/WebHID access.