@patimweb/pi-ssh 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.node-version ADDED
@@ -0,0 +1 @@
1
+ 26.5.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Patrick Weppelmann
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,146 @@
1
+ # pi-ssh
2
+
3
+ SSH client extension for the [pi coding agent](https://github.com/earendil-works/pi).
4
+
5
+ Run commands on remote hosts, move files over SFTP, and turn a password login into a key login — all from a pi session.
6
+
7
+ **Nothing has to be installed.** No `ssh`, no `ssh-keygen`, no `ssh-copy-id`. The SSH protocol comes from [ssh2](https://github.com/mscdex/ssh2), a pure JavaScript implementation, and keys are generated with Node's own crypto. Windows, macOS and Linux behave identically.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ # Install from npm
13
+ pi install npm:@patimweb/pi-ssh
14
+
15
+ # Install from local path during development
16
+ pi install /path/to/pi-ssh
17
+ ```
18
+
19
+ ## Quick Start
20
+
21
+ ```
22
+ ssh_setup:
23
+ name: staging
24
+ host: staging.example.com
25
+ user: deploy
26
+ password: <password>
27
+
28
+ ssh_authorize # generates a key, installs it, verifies passwordless login
29
+
30
+ ssh_exec:
31
+ command: systemctl status nginx --no-pager
32
+ ```
33
+
34
+ After `ssh_authorize` the password is no longer needed. It stays in the profile as a fallback until you pass `removePassword: true`.
35
+
36
+ ## Tools
37
+
38
+ | Tool | Description |
39
+ |------|-------------|
40
+ | `ssh_setup` | Store a host: address, user, and a password or key path. |
41
+ | `ssh_status` | List configured hosts and how each authenticates; optionally test a connection. |
42
+ | `ssh_profile` | List, switch, or delete hosts. |
43
+ | `ssh_exec` | Run a command and return its output and exit code. |
44
+ | `ssh_list` | List a remote directory with sizes, permissions and dates over SFTP. |
45
+ | `ssh_upload` | Copy a file to the remote host. |
46
+ | `ssh_download` | Copy a file from the remote host. |
47
+ | `ssh_keygen` | Create an ed25519 key pair in process. |
48
+ | `ssh_authorize` | Install a key on a host and stop needing the password. |
49
+ | `ssh_doctor` | Report what this machine can do and what needs fixing. |
50
+
51
+ ### Passwordless login
52
+
53
+ `ssh_authorize` is `ssh-copy-id` without the binary:
54
+
55
+ ```yaml
56
+ ssh_authorize:
57
+ # profile: staging # defaults to the active one
58
+ # keyPath: ~/.ssh/id_ed25519_pi_staging
59
+ # removePassword: true # only after verification succeeds
60
+ # authorizedKeysPath: /custom/authorized_keys
61
+ ```
62
+
63
+ It generates a key if the profile has none, appends the public key to the remote `~/.ssh/authorized_keys` with the permissions sshd requires, points the profile at the key, and then opens a **second connection using only the key** to prove it works before reporting success. Running it twice is safe: the key is appended only once.
64
+
65
+ An existing key at the target path is reused, never overwritten — overwriting would invalidate every other host that already trusts it.
66
+
67
+ ### Running commands
68
+
69
+ ```yaml
70
+ ssh_exec:
71
+ command: journalctl -u api --since "1 hour ago" --no-pager
72
+ # cwd: /srv/app
73
+ # timeoutSeconds: 120
74
+ ```
75
+
76
+ Each call opens a connection, runs one command, and closes it. There is no persistent shell, so `cd` does not carry over — use `cwd` or chain with `&&`. There is also no TTY: anything interactive (a `sudo` password prompt, `top`, an editor) will hang until the timeout.
77
+
78
+ Output is capped at 200,000 characters per stream and marked as truncated rather than silently cut.
79
+
80
+ ### Files
81
+
82
+ ```yaml
83
+ ssh_list: { path: /var/log }
84
+ ssh_download: { remotePath: /var/log/app.log, localPath: ./logs/app.log }
85
+ ssh_upload: { localPath: ./dist/app.tar.gz, remotePath: /tmp/app.tar.gz }
86
+ ```
87
+
88
+ Missing local directories are created on download. Prefer these over `cat` through `ssh_exec`: SFTP handles binary content and does not push the file through the model.
89
+
90
+ ## Commands
91
+
92
+ | Command | Description |
93
+ |---------|-------------|
94
+ | `/ssh <command>` | Run a command on the active host. |
95
+ | `/ssh-key` | Set up passwordless login for the active host. |
96
+
97
+ ## Skills
98
+
99
+ | Skill | Purpose |
100
+ |-------|---------|
101
+ | `ssh-remote-work` | Choosing the right host, the absence of a shell and a TTY, reading before writing, and what each connection error actually means. |
102
+ | `ssh-key-setup` | The password-to-key switch, why an existing key is never overwritten, and why the password stays until the key is proven. |
103
+
104
+ ## Host key verification
105
+
106
+ Host keys are checked against `~/.ssh/known_hosts` — the same file OpenSSH uses, so hosts you have already visited with `ssh` are recognised, and hosts recorded here are recognised by `ssh`. Hashed entries (`ssh-keygen -H`) and `[host]:port` forms are both understood.
107
+
108
+ - **Unknown host** → the connection is refused and the fingerprint shown. Confirm it, then re-run with `acceptNewHostKey: true` to record it.
109
+ - **Changed key** → a hard failure with both fingerprints. This is what a machine-in-the-middle looks like; it is also what a reinstalled server looks like. It has to be resolved deliberately with `ssh-keygen -R <host>`.
110
+ - **Revoked key** (`@revoked` in known_hosts) → always refused.
111
+
112
+ `strictHostKey: false` in a profile turns the check off. It removes the only protection SSH has against an attacker on the network path, so it exists for throwaway lab machines and nothing else.
113
+
114
+ ## Configuration
115
+
116
+ Profiles live in `~/.pi/ssh-config.json`, written atomically with mode `0600` because it can hold passwords and key passphrases.
117
+
118
+ ```json
119
+ {
120
+ "profiles": {
121
+ "staging": {
122
+ "host": "staging.example.com",
123
+ "port": 22,
124
+ "user": "deploy",
125
+ "privateKeyPath": "/home/pat/.ssh/id_ed25519_pi_staging"
126
+ }
127
+ },
128
+ "activeProfile": "staging"
129
+ }
130
+ ```
131
+
132
+ Every tool also takes a one-off `profile` parameter, so several hosts can be used in one session without switching.
133
+
134
+ ## Development
135
+
136
+ ```bash
137
+ npm install
138
+ npm test
139
+ npm run test:coverage
140
+ ```
141
+
142
+ The suite runs end-to-end against a real OpenSSH server: it starts `sshd` on a loopback port with a host key generated by this package, then exercises the handshake, host key verification (including a simulated key change), exit codes, SFTP, and the full key bootstrap. Those tests skip themselves on machines without `sshd` rather than failing.
143
+
144
+ ## License
145
+
146
+ MIT
package/index.ts ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * pi SSH Extension
3
+ *
4
+ * Runs commands and moves files on remote hosts over SSH, and can turn a
5
+ * password login into a key login without any external tooling.
6
+ *
7
+ * Tools:
8
+ * - ssh_setup: Store a host, user and credentials
9
+ * - ssh_status: List configured hosts, optionally testing a connection
10
+ * - ssh_profile: List, switch or delete hosts
11
+ * - ssh_exec: Run a command and return its output and exit code
12
+ * - ssh_list: List a remote directory over SFTP
13
+ * - ssh_upload: Copy a file to the remote host
14
+ * - ssh_download: Copy a file from the remote host
15
+ * - ssh_keygen: Create an ed25519 key pair in process
16
+ * - ssh_authorize: Install a key on a host and stop needing the password
17
+ * - ssh_doctor: Report what the environment can do and what needs fixing
18
+ *
19
+ * Nothing here shells out: ssh2 is a pure JavaScript SSH implementation and
20
+ * keys are generated with Node's own crypto, so Windows, macOS and Linux all
21
+ * work without ssh, ssh-keygen or ssh-copy-id being installed.
22
+ *
23
+ * Data-oriented design:
24
+ * - All domain data is represented as plain immutable interfaces (types.ts)
25
+ * - I/O is isolated in the client module (clients/)
26
+ * - Pure helpers have no sockets in them (keys.ts, known-hosts.ts, formatting/)
27
+ * - Each tool is a single-responsibility module (tools/)
28
+ */
29
+
30
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
31
+
32
+ import { loadConfig, getConfig } from "./src/config.ts";
33
+ import { SshSetupTool } from "./src/tools/ssh-setup.ts";
34
+ import { SshStatusTool } from "./src/tools/ssh-status.ts";
35
+ import { SshProfileTool } from "./src/tools/ssh-profile.ts";
36
+ import { SshExecTool } from "./src/tools/ssh-exec.ts";
37
+ import { SshListTool } from "./src/tools/ssh-list.ts";
38
+ import { SshUploadTool } from "./src/tools/ssh-upload.ts";
39
+ import { SshDownloadTool } from "./src/tools/ssh-download.ts";
40
+ import { SshKeygenTool } from "./src/tools/ssh-keygen.ts";
41
+ import { SshAuthorizeTool } from "./src/tools/ssh-authorize.ts";
42
+ import { SshDoctorTool } from "./src/tools/ssh-doctor.ts";
43
+
44
+ export default function (pi: ExtensionAPI) {
45
+ // Load saved hosts on startup
46
+ loadConfig();
47
+
48
+ // Register all tools
49
+ pi.registerTool(SshSetupTool);
50
+ pi.registerTool(SshStatusTool);
51
+ pi.registerTool(SshProfileTool);
52
+ pi.registerTool(SshExecTool);
53
+ pi.registerTool(SshListTool);
54
+ pi.registerTool(SshUploadTool);
55
+ pi.registerTool(SshDownloadTool);
56
+ pi.registerTool(SshKeygenTool);
57
+ pi.registerTool(SshAuthorizeTool);
58
+ pi.registerTool(SshDoctorTool);
59
+
60
+ // Run something on the active host without spelling out the tool call
61
+ pi.registerCommand("ssh", {
62
+ description: "Run a command on the active SSH host",
63
+ handler: async (args, ctx) => {
64
+ if (!getConfig()) {
65
+ ctx.ui.notify("No SSH host configured. Use the ssh_setup tool first.", "error");
66
+ return;
67
+ }
68
+
69
+ const command = args.trim();
70
+ if (!command) {
71
+ ctx.ui.notify("Usage: /ssh <command to run on the remote host>", "info");
72
+ return;
73
+ }
74
+
75
+ pi.sendUserMessage(
76
+ `Run this on the active SSH host with ssh_exec and show me the output: ${command}`,
77
+ { deliverAs: "steer" },
78
+ );
79
+ ctx.ui.notify("Running on the remote host...", "info");
80
+ },
81
+ });
82
+
83
+ // The one-time setup people forget until they are typing a password again
84
+ pi.registerCommand("ssh-key", {
85
+ description: "Set up passwordless login for the active SSH host",
86
+ handler: async (_args, ctx) => {
87
+ if (!getConfig()) {
88
+ ctx.ui.notify("No SSH host configured. Use the ssh_setup tool first.", "error");
89
+ return;
90
+ }
91
+
92
+ pi.sendUserMessage(
93
+ "Use ssh_authorize on the active SSH profile to generate a key, install it on the host and verify that a key-only login works. Tell me the fingerprint and whether the verification succeeded.",
94
+ { deliverAs: "steer" },
95
+ );
96
+ ctx.ui.notify("Setting up key-based login...", "info");
97
+ },
98
+ });
99
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@patimweb/pi-ssh",
3
+ "version": "1.0.0",
4
+ "description": "SSH client extension for the pi coding agent. Run commands, transfer files over SFTP, and set up key-based login on remote hosts.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "ssh",
9
+ "sftp",
10
+ "remote",
11
+ "ssh2"
12
+ ],
13
+ "author": "Patrick Weppelmann",
14
+ "license": "MIT",
15
+ "type": "module",
16
+ "engines": {
17
+ "node": ">=26.0.0"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/Smotherer007/pi-ssh.git"
22
+ },
23
+ "bugs": {
24
+ "url": "https://github.com/Smotherer007/pi-ssh/issues"
25
+ },
26
+ "scripts": {
27
+ "build": "echo 'No build step defined'",
28
+ "test": "node --experimental-test-module-mocks --experimental-test-coverage --test 'tests/**/*.test.ts'",
29
+ "test:watch": "node --experimental-test-module-mocks --test --watch 'tests/**/*.test.ts'",
30
+ "test:coverage": "node --experimental-test-module-mocks --experimental-test-coverage --test 'tests/**/*.test.ts'"
31
+ },
32
+ "dependencies": {
33
+ "ssh2": "^1.17.0",
34
+ "typebox": "^1.2.8"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "devDependencies": {
40
+ "@semantic-release/git": "^10.0.0",
41
+ "@types/ssh2": "^1.15.0",
42
+ "semantic-release": "^25.0.0"
43
+ },
44
+ "pi": {
45
+ "extensions": [
46
+ "./index.ts"
47
+ ],
48
+ "skills": [
49
+ "./skills"
50
+ ]
51
+ }
52
+ }
@@ -0,0 +1,16 @@
1
+ module.exports = {
2
+ branches: ["main", { name: "next", prerelease: true }],
3
+ plugins: [
4
+ "@semantic-release/commit-analyzer",
5
+ "@semantic-release/release-notes-generator",
6
+ "@semantic-release/npm",
7
+ [
8
+ "@semantic-release/git",
9
+ {
10
+ assets: ["package.json", "package-lock.json"],
11
+ message: "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
12
+ }
13
+ ],
14
+ "@semantic-release/github"
15
+ ]
16
+ };
@@ -0,0 +1,71 @@
1
+ ---
2
+ name: ssh-key-setup
3
+ description: Set up SSH key authentication so a host stops asking for a password, and manage the keys involved. Use when the user configures a new server with a password, says they are tired of typing a password, asks about ssh-copy-id, ssh-keygen, "install my key", "passwordless login", "authorized_keys", or when a login fails because a key is missing or not accepted. Also covers what has to be installed for any of this to work, which on every platform is nothing.
4
+ allowed-tools: ssh_setup, ssh_status, ssh_keygen, ssh_authorize, ssh_exec, ssh_doctor
5
+ ---
6
+
7
+ # Key-based login
8
+
9
+ A password in a config file is a password on disk, and typing one into every
10
+ session is friction. A key fixes both. The whole switch is one tool call.
11
+
12
+ ## The normal path
13
+
14
+ 1. `ssh_setup` with host, user and the password.
15
+ 2. `ssh_authorize` on that profile.
16
+
17
+ `ssh_authorize` does what `ssh-copy-id` does: it generates an ed25519 key if
18
+ the profile has none, appends the public key to the remote
19
+ `~/.ssh/authorized_keys` with the permissions sshd insists on, points the
20
+ profile at the new key, and then **opens a second connection using only the
21
+ key** to prove it works before claiming success.
22
+
23
+ Report the fingerprint and whether verification succeeded. If it did not, say
24
+ so plainly - the key is installed but something is rejecting it, and the
25
+ password is still there as a fallback.
26
+
27
+ ## What has to be installed
28
+
29
+ Nothing. Not `ssh`, not `ssh-keygen`, not `ssh-copy-id`. The SSH protocol is
30
+ implemented in JavaScript and keys are generated with Node's own crypto, so
31
+ Windows, macOS and Linux behave identically. If a user asks what to install,
32
+ run `ssh_doctor` and show them the report rather than guessing.
33
+
34
+ The keys produced are ordinary OpenSSH ed25519 keys, so `ssh -i` and any other
35
+ SSH client can use the same file.
36
+
37
+ ## Things worth getting right
38
+
39
+ - **Never overwrite an existing key.** Every host that already trusts it would
40
+ stop accepting it. `ssh_authorize` reuses a key at the given path; `ssh_keygen`
41
+ refuses to replace one unless explicitly told to.
42
+ - **Keep the password until the key is proven.** `ssh_authorize` leaves it in
43
+ the profile by default. Only pass `removePassword: true` once verification
44
+ has succeeded, and prefer to ask the user first: if `authorized_keys` is ever
45
+ reset, that password is the way back in.
46
+ - **One key per host is a reasonable default.** The default path is
47
+ `~/.ssh/id_ed25519_pi_<profile>`, which keeps a compromise contained to one
48
+ host. Pass `keyPath` to reuse an existing key across hosts if the user wants
49
+ that.
50
+ - **A passphrase-protected key cannot be bootstrapped.** If the user points at
51
+ one, this flow cannot read it; either use a different key path or configure
52
+ the passphrase in the profile.
53
+
54
+ ## If the key is not accepted afterwards
55
+
56
+ The usual causes, in order:
57
+
58
+ 1. Permissions. sshd silently ignores `~/.ssh` or `authorized_keys` if they are
59
+ group- or world-writable. `ssh_exec` with `ls -ld ~/.ssh ~/.ssh/authorized_keys`
60
+ shows it; 700 and 600 are what is wanted.
61
+ 2. The account's home is not where you think - a different `AuthorizedKeysFile`
62
+ in `sshd_config`, or a chrooted account. `ssh_authorize` takes an
63
+ `authorizedKeysPath` for that case.
64
+ 3. The server does not allow public key auth at all (`PubkeyAuthentication no`),
65
+ which needs a change on the server side.
66
+
67
+ ## Keys without a host
68
+
69
+ `ssh_keygen` just makes a key pair and prints the public key, for cases where
70
+ the user installs it themselves - a cloud provider's web console, a deploy key
71
+ in a Git host, a colleague who will add it for them.
@@ -0,0 +1,81 @@
1
+ ---
2
+ name: ssh-remote-work
3
+ description: Work on a remote machine over SSH - run commands, inspect logs and configuration, and move files with SFTP. Use whenever the task is on another host: deploying or restarting a service, reading logs on a server, checking disk or process state, copying a build artefact to or from a machine, or anything the user describes as "on the server", "on the NAS", "on staging", or by a hostname. Also covers what to do when a connection fails and how to read a host key warning.
4
+ allowed-tools: ssh_status, ssh_profile, ssh_exec, ssh_list, ssh_upload, ssh_download, ssh_doctor
5
+ ---
6
+
7
+ # Working on a remote host
8
+
9
+ Commands run through `ssh_exec` land on someone's actual machine. That is the
10
+ whole value and also the whole risk: there is no undo, and the blast radius is
11
+ whatever the account can reach.
12
+
13
+ ## Before the first command
14
+
15
+ `ssh_status` shows which hosts are configured and which one is active. **When
16
+ more than one exists, pass `profile` explicitly** rather than relying on
17
+ whichever was active from an earlier session - "restart nginx" on the wrong
18
+ host is not recoverable by apologising.
19
+
20
+ If the user names a host you have no profile for, say so and ask for the
21
+ details rather than guessing at a hostname.
22
+
23
+ ## Running commands
24
+
25
+ `ssh_exec` opens a connection, runs one command, and closes it. There is no
26
+ persistent shell, which has two consequences worth remembering:
27
+
28
+ - **State does not carry over.** `cd /var/log` in one call does not affect the
29
+ next. Use the `cwd` parameter, or chain with `&&` in a single command.
30
+ - **There is no TTY.** Anything interactive hangs until the timeout: `sudo`
31
+ that prompts for a password, `top`, `vim`, `apt` without `-y`. Use
32
+ non-interactive forms (`sudo -n`, `apt-get -y`, `systemctl --no-pager`), and
33
+ if a password prompt is genuinely needed, tell the user rather than trying to
34
+ feed it in.
35
+
36
+ Read the exit code, not just the output. A command that printed nothing and
37
+ exited 1 failed; reporting "done" because stdout was empty is wrong.
38
+
39
+ ## Reading before writing
40
+
41
+ Prefer commands that observe over commands that change, and look before you
42
+ act: `systemctl status` before `systemctl restart`, `ls` before `rm`, a diff
43
+ before an overwrite.
44
+
45
+ For anything destructive or disruptive - deleting files, restarting services,
46
+ changing configuration, `chmod`/`chown` on system paths, package installs,
47
+ anything with `sudo` - **say what you are about to run and why, and let the
48
+ user confirm**, unless they already asked for exactly that. Piping a remote
49
+ script into a shell (`curl ... | sh`) is not something to do on someone's
50
+ server on your own initiative.
51
+
52
+ ## Files
53
+
54
+ `ssh_list` gives a structured listing with sizes, modes and dates - use it
55
+ rather than parsing `ls` output. `ssh_download` and `ssh_upload` move files
56
+ over SFTP; prefer them over `cat`-ing a file through `ssh_exec`, which mangles
57
+ binaries and pushes the whole content through the model for nothing.
58
+
59
+ ## When it does not connect
60
+
61
+ The error usually says which layer failed. Work from it rather than retrying:
62
+
63
+ - **"not in known_hosts"** - the host has never been seen. Show the user the
64
+ fingerprint and ask them to confirm it against the server before re-running
65
+ with `acceptNewHostKey: true`. Do not pass that flag reflexively; it is the
66
+ moment the trust decision is made.
67
+ - **"HOST KEY CHANGED"** - stop. This is either a reinstalled server or
68
+ someone between you and it. Never work around it by disabling host key
69
+ checking. Relay the message; the user has to resolve it deliberately.
70
+ - **"rejected the credentials"** - user name, password or key is wrong, or the
71
+ key is not in the remote authorized_keys. `ssh_authorize` fixes the last one.
72
+ - **"refused the connection"** - nothing is listening: wrong port, or sshd is
73
+ down.
74
+ - Something about the local environment - run `ssh_doctor`, which reports what
75
+ is missing and what to do about it.
76
+
77
+ ## What to report back
78
+
79
+ Give the user the command's actual output and its exit code. When a command
80
+ failed, quote stderr rather than summarising it - the exact message is what
81
+ they need. Say which host you ran it on.