ssh-runner
Execute read-only commands on remote Linux servers via SSH using the ssh2 npm package.
Best use case
ssh-runner is best used when you need a repeatable AI agent workflow instead of a one-off prompt.
Execute read-only commands on remote Linux servers via SSH using the ssh2 npm package.
Teams using ssh-runner should expect a more consistent output, faster repeated execution, less prompt rewriting.
When to use this skill
- You want a reusable workflow that can be run more than once with consistent structure.
When not to use this skill
- You only need a quick one-off answer and do not need a reusable workflow.
- You cannot install or maintain the underlying files, dependencies, or repository context.
Installation
Claude Code / Cursor / Codex
Manual Installation
- Download SKILL.md from GitHub
- Place it in
.claude/skills/ssh-runner/SKILL.mdinside your project - Restart your AI agent — it will auto-discover the skill
How ssh-runner Compares
| Feature / Agent | ssh-runner | Standard Approach |
|---|---|---|
| Platform Support | Not specified | Limited / Varies |
| Context Awareness | High | Baseline |
| Installation Complexity | Unknown | N/A |
Frequently Asked Questions
What does this skill do?
Execute read-only commands on remote Linux servers via SSH using the ssh2 npm package.
Where can I find the source code?
You can find the source code on GitHub using the link provided at the top of the page.
SKILL.md Source
## When to use this skill
Use this skill when implementing SSH-based remote command execution in Node.js/TypeScript projects using the `ssh2` package. Covers connecting, running commands, parsing output, handling auth methods, concurrency, and error handling.
## Installation
```bash
pnpm add ssh2
pnpm add -D @types/ssh2
```
## Basic Connection and Command Execution
```typescript
import { Client } from 'ssh2';
import fs from 'fs';
async function runCommand(
host: string,
port: number,
username: string,
keyPath: string,
command: string
): Promise<string> {
return new Promise((resolve, reject) => {
const conn = new Client();
conn.on('ready', () => {
conn.exec(command, (err, stream) => {
if (err) {
conn.end();
reject(err);
return;
}
let stdout = '';
let stderr = '';
stream.on('data', (data: Buffer) => { stdout += data.toString(); });
stream.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
stream.on('close', (code: number) => {
conn.end();
if (code !== 0 && stderr) {
reject(new Error(stderr.trim()));
} else {
resolve(stdout.trim());
}
});
});
});
conn.on('error', reject);
conn.connect({
host,
port,
username,
privateKey: fs.readFileSync(keyPath),
readyTimeout: 10000,
});
});
}
```
## Running Multiple Commands in One Session
Running each command in a separate session adds overhead. Reuse one connection:
```typescript
async function runCommands(
config: ConnectConfig,
commands: string[]
): Promise<Map<string, string>> {
return new Promise((resolve, reject) => {
const conn = new Client();
const results = new Map<string, string>();
conn.on('ready', () => {
const runNext = (i: number) => {
if (i >= commands.length) {
conn.end();
resolve(results);
return;
}
conn.exec(commands[i], (err, stream) => {
if (err) { conn.end(); reject(err); return; }
let out = '';
stream.on('data', (d: Buffer) => { out += d.toString(); });
stream.stderr.on('data', () => {}); // drain stderr
stream.on('close', () => {
results.set(commands[i], out.trim());
runNext(i + 1);
});
});
};
runNext(0);
});
conn.on('error', reject);
conn.connect(config);
});
}
```
## Authentication Methods
### SSH Key (recommended)
```typescript
conn.connect({
host: '192.168.1.10',
port: 22,
username: 'ubuntu',
privateKey: fs.readFileSync('/root/.ssh/id_rsa'),
readyTimeout: 10000,
});
```
### Password
```typescript
conn.connect({
host: '192.168.1.10',
port: 22,
username: 'ubuntu',
password: 'secret',
readyTimeout: 10000,
});
```
### ConnectConfig type
```typescript
import type { ConnectConfig } from 'ssh2';
const config: ConnectConfig = {
host: server.host,
port: server.port,
username: server.username,
privateKey: server.keyPath ? fs.readFileSync(server.keyPath) : undefined,
password: server.authMethod === 'password' ? server.password : undefined,
readyTimeout: Number(process.env.SSH_TIMEOUT_MS ?? 10000),
};
```
## Concurrency Limiter
For fleet scans, use a semaphore to cap parallel connections:
```typescript
async function scanWithConcurrency<T>(
items: T[],
concurrency: number,
fn: (item: T) => Promise<void>
): Promise<void> {
const queue = [...items];
const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
while (queue.length > 0) {
const item = queue.shift()!;
await fn(item).catch((err) => {
console.error('Scan error:', err.message);
});
}
});
await Promise.all(workers);
}
// Usage
await scanWithConcurrency(servers, SCAN_CONCURRENCY, async (server) => {
await collectServer(server);
});
```
## Parsing Common Linux Command Output
### /etc/os-release
```typescript
function parseOsRelease(raw: string): { name: string; version: string } {
const lines = raw.split('\n');
const kv: Record<string, string> = {};
for (const line of lines) {
const [k, ...rest] = line.split('=');
if (k) kv[k.trim()] = rest.join('=').trim().replace(/^"|"$/g, '');
}
return {
name: kv['PRETTY_NAME'] ?? kv['NAME'] ?? 'Unknown',
version: kv['VERSION_ID'] ?? '',
};
}
```
### free -m
```typescript
function parseFreeMem(raw: string): { totalMb: number; usedMb: number } {
// Output line: "Mem: 32768 14200 8000 400 10000 18000"
const line = raw.split('\n').find((l) => l.startsWith('Mem:'));
if (!line) return { totalMb: 0, usedMb: 0 };
const parts = line.split(/\s+/);
return {
totalMb: Number(parts[1]),
usedMb: Number(parts[2]),
};
}
```
### df -m
```typescript
interface DiskMount {
mountpoint: string;
deviceName: string;
totalMb: number;
usedMb: number;
usedPercent: number;
}
function parseDf(raw: string): DiskMount[] {
const lines = raw.trim().split('\n').slice(1); // skip header
return lines
.map((line) => {
const parts = line.trim().split(/\s+/);
// source target size used pcent
if (parts.length < 5) return null;
return {
deviceName: parts[0],
mountpoint: parts[1],
totalMb: Number(parts[2]),
usedMb: Number(parts[3]),
usedPercent: Number(parts[4].replace('%', '')),
};
})
.filter(Boolean) as DiskMount[];
}
```
### /proc/loadavg
```typescript
function parseLoadAvg(raw: string): [number, number, number] {
const parts = raw.trim().split(' ');
return [
parseFloat(parts[0]),
parseFloat(parts[1]),
parseFloat(parts[2]),
];
}
```
### /proc/cpuinfo
```typescript
function parseCpuModel(raw: string): { model: string; cores: number } {
const modelLine = raw.split('\n').find((l) => l.startsWith('model name'));
const model = modelLine?.split(':')[1]?.trim() ?? 'Unknown';
return { model, cores: 0 }; // cores come from nproc
}
```
## Timeout Handling
`readyTimeout` controls how long to wait for the SSH handshake. If the server is unreachable, ssh2 emits an `error` event with `code: 'ETIMEDOUT'` after this duration.
```typescript
conn.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'ETIMEDOUT') {
// Record as 'timeout' in scan_log
} else if (err.message?.includes('authentication')) {
// Record as 'error' with 'SSH auth failed'
} else {
// Record as 'error' with err.message
}
reject(err);
});
```
## Error Handling Reference
| Error message | Cause | Resolution |
|---------------|-------|------------|
| `connect ETIMEDOUT` | Server unreachable or firewall blocking port 22 | Check network, firewall, SSH daemon |
| `All configured authentication methods failed` | Wrong key or key not in authorized_keys | Check key path, permissions, authorized_keys |
| `ENOENT` on key file | Key file not found at keyPath | Check container volume mount and keyPath value |
| `Handshake failed` | SSH server incompatibility or network interruption | Check SSH server version, retry |
| `read ECONNRESET` | Connection dropped mid-session | Retry; may indicate network instability |
## Key Security Notes
- Never store private key contents in the database. Store only the file path.
- Never transmit private key contents over the API.
- Mount SSH keys into the container as read-only: `~/.ssh:/root/.ssh:ro`.
- Only run read-only commands (`cat`, `uname`, `hostname`, `free`, `df`, `nproc`). Never `sudo`, `rm`, or write commands.
- Use `readyTimeout` to prevent hung connections from blocking the scan queue.Related Skills
load-test-runner
No description provided.
task-runner
Run monorepo tasks with dependency ordering using monorepo-task-runner. Covers mtr run, mtr status, tasks.yaml format, and the web dashboard.
eval-runner
Run LLM evaluation test suites and detect regressions. Use when you need to: test LLM responses against expected outputs, score responses with exact match, regex, or AI judge, compare model performance across runs, detect quality regressions in CI, or benchmark multiple models. Triggers include "LLM eval", "test my prompts", "evaluate model", "run evals", "regression test LLM", "score responses", "compare models", or any task requiring systematic LLM quality measurement.
Skill: Uptime Monitoring
## Overview
Skill: Status Page
## Overview
Skill: unit-conversion
## Overview
Skill: recipe-scaler
## Overview
reading-list
Operate the reading-list API to save, manage, tag, search, and export articles.
email-digest
Configure, test, and troubleshoot the reading-list daily email digest delivered via nodemailer.
websocket-realtime
Use the WebSocket connection in poll-builder to receive live vote updates. Use when you need to stream real-time poll results, monitor a poll for new votes, or build a live dashboard. Triggers include "live results", "real-time updates", "stream votes", "watch poll", or "WebSocket".
poll-builder
Self-hosted poll creation tool with real-time results. Use when you need to create a poll, check vote counts, close a poll, export results, or get the shareable link for a poll. Triggers include "create poll", "vote", "poll results", "survey", "collect votes", "share poll", or any task involving polling or voting.
Skill: personal-finance
## Overview