ssh-runner

Execute read-only commands on remote Linux servers via SSH using the ssh2 npm package.

7 stars

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

$curl -o ~/.claude/skills/ssh-runner/SKILL.md --create-dirs "https://raw.githubusercontent.com/heldernoid/agentic-build-templates/main/projects/devops-infrastructure/server-inventory/skills/ssh-runner/SKILL.md"

Manual Installation

  1. Download SKILL.md from GitHub
  2. Place it in .claude/skills/ssh-runner/SKILL.md inside your project
  3. Restart your AI agent — it will auto-discover the skill

How ssh-runner Compares

Feature / Agentssh-runnerStandard Approach
Platform SupportNot specifiedLimited / Varies
Context Awareness High Baseline
Installation ComplexityUnknownN/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

7
from heldernoid/agentic-build-templates

No description provided.

task-runner

7
from heldernoid/agentic-build-templates

Run monorepo tasks with dependency ordering using monorepo-task-runner. Covers mtr run, mtr status, tasks.yaml format, and the web dashboard.

eval-runner

7
from heldernoid/agentic-build-templates

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

7
from heldernoid/agentic-build-templates

## Overview

Skill: Status Page

7
from heldernoid/agentic-build-templates

## Overview

Skill: unit-conversion

7
from heldernoid/agentic-build-templates

## Overview

Skill: recipe-scaler

7
from heldernoid/agentic-build-templates

## Overview

reading-list

7
from heldernoid/agentic-build-templates

Operate the reading-list API to save, manage, tag, search, and export articles.

email-digest

7
from heldernoid/agentic-build-templates

Configure, test, and troubleshoot the reading-list daily email digest delivered via nodemailer.

websocket-realtime

7
from heldernoid/agentic-build-templates

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

7
from heldernoid/agentic-build-templates

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

7
from heldernoid/agentic-build-templates

## Overview