init: pi-extensions with ssh-controlmaster
This commit is contained in:
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 joakimp
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# pi-extensions
|
||||||
|
|
||||||
|
Custom and modified extensions for the [pi coding-agent](https://github.com/mariozechner/pi-coding-agent).
|
||||||
|
|
||||||
|
This repo is the single source of truth for extensions that aren't suitable for general publishing — personal workflow tweaks, modified versions of built-in examples, and extensions written for specific infrastructure. Symlinked into `~/.pi/agent/extensions/` so pi loads them automatically.
|
||||||
|
|
||||||
|
Part of the same family as [`pi-toolkit`](https://gitea.jordbo.se/joakimp/pi-toolkit) (bring-up) and [`skillset`](https://gitea.jordbo.se/joakimp/skillset) (agent skills).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone ssh://git@gitea.jordbo.se:2222/joakimp/pi-extensions.git ~/src/src_local/pi-extensions
|
||||||
|
cd ~/src/src_local/pi-extensions
|
||||||
|
chmod +x install.sh
|
||||||
|
./install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Each `.ts` file in `extensions/` is symlinked into `~/.pi/agent/extensions/`. Existing real files are backed up with a timestamp. Re-runs are idempotent.
|
||||||
|
|
||||||
|
### Alternative: pi install (local path)
|
||||||
|
|
||||||
|
Because `package.json` declares a `pi` manifest, you can also register this repo as a pi package:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pi install ~/src/src_local/pi-extensions
|
||||||
|
```
|
||||||
|
|
||||||
|
This makes pi manage the extension loading directly. The `install.sh` approach (symlinks) and `pi install` are mutually exclusive for the same extension — pick one per machine.
|
||||||
|
|
||||||
|
### Uninstall
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./install.sh --uninstall
|
||||||
|
```
|
||||||
|
|
||||||
|
Removes symlinks that point into this repo. Your own files in `~/.pi/agent/extensions/` are never touched.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Extensions
|
||||||
|
|
||||||
|
### `ssh-controlmaster.ts`
|
||||||
|
|
||||||
|
Transparent SSH remote execution via a persistent ControlMaster socket.
|
||||||
|
|
||||||
|
When launched with `--ssh user@host`, all of pi's native file and shell tools
|
||||||
|
(`read`, `write`, `edit`, `bash`) are transparently redirected to execute on
|
||||||
|
the remote machine. One SSH connection is established at session start; all
|
||||||
|
subsequent tool calls multiplex over it via a Unix socket. Much faster than
|
||||||
|
the plain `ssh.ts` example which opens a new connection per tool call.
|
||||||
|
|
||||||
|
**Use cases:**
|
||||||
|
- Diagnose and fix issues on a remote server without installing pi there
|
||||||
|
- Work on Proxmox hosts, LXC containers, or ephemeral VMs
|
||||||
|
- Any machine you have SSH key access to but don't own
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Remote home dir resolved automatically via pwd
|
||||||
|
pi --ssh user@192.168.1.10
|
||||||
|
|
||||||
|
# Explicit remote path (skips the initial pwd call)
|
||||||
|
pi --ssh root@proxmox-node:/etc/pve
|
||||||
|
|
||||||
|
# Try without modifying your global install
|
||||||
|
pi -e ~/src/src_local/pi-extensions/extensions/ssh-controlmaster.ts --ssh user@host
|
||||||
|
```
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
- SSH key-based auth (no password prompts — ControlMaster won't work with interactive auth)
|
||||||
|
- `bash` available on the remote
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
|
||||||
|
1. On `session_start`, establishes a ControlMaster: `ssh -fN -o ControlMaster=yes -o ControlPersist=yes -o ControlPath=<socket> <remote>`
|
||||||
|
2. All tool calls use `-o ControlMaster=no -o ControlPath=<socket>` to multiplex over that socket — near-zero per-call overhead
|
||||||
|
3. The system prompt is patched to tell the LLM it's operating on `<remoteCwd> (via SSH ControlMaster: <remote>)`
|
||||||
|
4. User `!` shell commands are also routed over SSH
|
||||||
|
5. On `session_shutdown`, sends `ssh -O exit` to cleanly tear down the master
|
||||||
|
|
||||||
|
**Status bar:** Shows `SSH ⚡ user@host:/path` when the master is ready, `⟳ connecting…` during setup, and an error state if the master fails to start.
|
||||||
|
|
||||||
|
**Path mapping:** Paths are rewritten by replacing the local `cwd` with the remote `cwd`. This means pi should be started from a directory that maps cleanly to a path on the remote. Use the `user@host:/explicit/path` form when the remote path differs significantly from your local working directory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding a new extension
|
||||||
|
|
||||||
|
1. Drop a `.ts` file into `extensions/`
|
||||||
|
2. Re-run `./install.sh` — it picks up the new file and symlinks it
|
||||||
|
3. In a running pi session, `/reload` is enough; no restart needed
|
||||||
|
|
||||||
|
Each extension is a TypeScript module loaded by [jiti](https://github.com/unjs/jiti) — no compilation step. See the [pi extensions docs](https://github.com/mariozechner/pi-coding-agent/blob/main/docs/extensions.md) and the [built-in examples](https://github.com/mariozechner/pi-coding-agent/tree/main/examples/extensions) for the API surface.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deploying on a new machine
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Prerequisites: pi installed, SSH key auth working
|
||||||
|
pi --help # creates ~/.pi/agent/ on first run
|
||||||
|
|
||||||
|
# 2. Clone and install
|
||||||
|
git clone ssh://git@gitea.jordbo.se:2222/joakimp/pi-extensions.git ~/src/src_local/pi-extensions
|
||||||
|
cd ~/src/src_local/pi-extensions && ./install.sh
|
||||||
|
|
||||||
|
# 3. Verify
|
||||||
|
ls -la ~/.pi/agent/extensions/ # should show symlinks into this repo
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related repos
|
||||||
|
|
||||||
|
- [`pi-toolkit`](https://gitea.jordbo.se/joakimp/pi-toolkit) — pi bring-up: settings template, keybindings, shell env loader
|
||||||
|
- [`mempalace-toolkit`](https://gitea.jordbo.se/joakimp/mempalace-toolkit) — persistent memory layer for pi via MemPalace MCP
|
||||||
|
- [`skillset`](https://gitea.jordbo.se/joakimp/skillset) — agent skills for pi, opencode, and Claude
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT — see [`LICENSE`](LICENSE).
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
/**
|
||||||
|
* SSH ControlMaster Remote Execution
|
||||||
|
*
|
||||||
|
* Like the ssh.ts example but uses a persistent ControlMaster socket so all
|
||||||
|
* tool calls (read, write, edit, bash) multiplex over a single SSH connection
|
||||||
|
* instead of opening a new one each time. Much faster on slow links or when
|
||||||
|
* pi makes many small file reads in sequence.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* pi -e ~/.pi/agent/extensions/ssh-controlmaster.ts --ssh user@host
|
||||||
|
* pi -e ~/.pi/agent/extensions/ssh-controlmaster.ts --ssh user@host:/remote/path
|
||||||
|
*
|
||||||
|
* Or, once installed globally, just:
|
||||||
|
* pi --ssh user@host
|
||||||
|
*
|
||||||
|
* Requirements:
|
||||||
|
* - SSH key-based auth (no password prompts)
|
||||||
|
* - bash on the remote machine
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||||
|
import {
|
||||||
|
type BashOperations,
|
||||||
|
createBashTool,
|
||||||
|
createEditTool,
|
||||||
|
createReadTool,
|
||||||
|
createWriteTool,
|
||||||
|
type EditOperations,
|
||||||
|
type ReadOperations,
|
||||||
|
type WriteOperations,
|
||||||
|
} from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
|
// ── ControlMaster helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function controlSocketPath(): string {
|
||||||
|
// Keep path short — macOS has a ~104-char Unix socket path limit
|
||||||
|
return join(tmpdir(), `pi-cm-${process.pid}.sock`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startControlMaster(remote: string, socketPath: string): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// -fN: fork to background after auth, don't run a remote command
|
||||||
|
// ControlPersist=yes: keep the master alive in the background indefinitely
|
||||||
|
const child = spawn(
|
||||||
|
"ssh",
|
||||||
|
[
|
||||||
|
"-fN",
|
||||||
|
"-o", "ControlMaster=yes",
|
||||||
|
"-o", `ControlPath=${socketPath}`,
|
||||||
|
"-o", "ControlPersist=yes",
|
||||||
|
remote,
|
||||||
|
],
|
||||||
|
{ stdio: "ignore" },
|
||||||
|
);
|
||||||
|
child.on("error", reject);
|
||||||
|
child.on("close", (code) => {
|
||||||
|
// -f forks to background; parent exits 0 once master is ready
|
||||||
|
if (code === 0) resolve();
|
||||||
|
else reject(new Error(`ControlMaster exited with code ${code}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopControlMaster(remote: string, socketPath: string): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
// -O exit sends the exit signal to the master process via the socket
|
||||||
|
const child = spawn(
|
||||||
|
"ssh",
|
||||||
|
["-O", "exit", "-o", `ControlPath=${socketPath}`, remote],
|
||||||
|
{ stdio: "ignore" },
|
||||||
|
);
|
||||||
|
child.on("close", () => resolve()); // best-effort; ignore errors on cleanup
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SSH exec (multiplexed over ControlMaster socket) ────────────────────────
|
||||||
|
|
||||||
|
function sshExec(remote: string, socketPath: string, command: string): Promise<Buffer> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(
|
||||||
|
"ssh",
|
||||||
|
[
|
||||||
|
"-o", "ControlMaster=no", // use existing master, don't create a new one
|
||||||
|
"-o", `ControlPath=${socketPath}`,
|
||||||
|
remote,
|
||||||
|
command,
|
||||||
|
],
|
||||||
|
{ stdio: ["ignore", "pipe", "pipe"] },
|
||||||
|
);
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
const errChunks: Buffer[] = [];
|
||||||
|
child.stdout.on("data", (d: Buffer) => chunks.push(d));
|
||||||
|
child.stderr.on("data", (d: Buffer) => errChunks.push(d));
|
||||||
|
child.on("error", reject);
|
||||||
|
child.on("close", (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
reject(new Error(`SSH failed (${code}): ${Buffer.concat(errChunks).toString()}`));
|
||||||
|
} else {
|
||||||
|
resolve(Buffer.concat(chunks));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Remote tool operations ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function createRemoteReadOps(
|
||||||
|
remote: string,
|
||||||
|
socketPath: string,
|
||||||
|
remoteCwd: string,
|
||||||
|
localCwd: string,
|
||||||
|
): ReadOperations {
|
||||||
|
const toRemote = (p: string) => p.replace(localCwd, remoteCwd);
|
||||||
|
return {
|
||||||
|
readFile: (p) => sshExec(remote, socketPath, `cat ${JSON.stringify(toRemote(p))}`),
|
||||||
|
access: (p) =>
|
||||||
|
sshExec(remote, socketPath, `test -r ${JSON.stringify(toRemote(p))}`).then(() => {}),
|
||||||
|
detectImageMimeType: async (p) => {
|
||||||
|
try {
|
||||||
|
const r = await sshExec(
|
||||||
|
remote,
|
||||||
|
socketPath,
|
||||||
|
`file --mime-type -b ${JSON.stringify(toRemote(p))}`,
|
||||||
|
);
|
||||||
|
const m = r.toString().trim();
|
||||||
|
return ["image/jpeg", "image/png", "image/gif", "image/webp"].includes(m)
|
||||||
|
? (m as "image/jpeg" | "image/png" | "image/gif" | "image/webp")
|
||||||
|
: null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRemoteWriteOps(
|
||||||
|
remote: string,
|
||||||
|
socketPath: string,
|
||||||
|
remoteCwd: string,
|
||||||
|
localCwd: string,
|
||||||
|
): WriteOperations {
|
||||||
|
const toRemote = (p: string) => p.replace(localCwd, remoteCwd);
|
||||||
|
return {
|
||||||
|
writeFile: async (p, content) => {
|
||||||
|
const b64 = Buffer.from(content).toString("base64");
|
||||||
|
await sshExec(
|
||||||
|
remote,
|
||||||
|
socketPath,
|
||||||
|
`echo ${JSON.stringify(b64)} | base64 -d > ${JSON.stringify(toRemote(p))}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
mkdir: (dir) =>
|
||||||
|
sshExec(remote, socketPath, `mkdir -p ${JSON.stringify(toRemote(dir))}`).then(() => {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRemoteEditOps(
|
||||||
|
remote: string,
|
||||||
|
socketPath: string,
|
||||||
|
remoteCwd: string,
|
||||||
|
localCwd: string,
|
||||||
|
): EditOperations {
|
||||||
|
const r = createRemoteReadOps(remote, socketPath, remoteCwd, localCwd);
|
||||||
|
const w = createRemoteWriteOps(remote, socketPath, remoteCwd, localCwd);
|
||||||
|
return { readFile: r.readFile, access: r.access, writeFile: w.writeFile };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRemoteBashOps(
|
||||||
|
remote: string,
|
||||||
|
socketPath: string,
|
||||||
|
remoteCwd: string,
|
||||||
|
localCwd: string,
|
||||||
|
): BashOperations {
|
||||||
|
const toRemote = (p: string) => p.replace(localCwd, remoteCwd);
|
||||||
|
return {
|
||||||
|
exec: (command, cwd, { onData, signal, timeout }) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const cmd = `cd ${JSON.stringify(toRemote(cwd))} && ${command}`;
|
||||||
|
const child = spawn(
|
||||||
|
"ssh",
|
||||||
|
[
|
||||||
|
"-o", "ControlMaster=no",
|
||||||
|
"-o", `ControlPath=${socketPath}`,
|
||||||
|
remote,
|
||||||
|
cmd,
|
||||||
|
],
|
||||||
|
{ stdio: ["ignore", "pipe", "pipe"] },
|
||||||
|
);
|
||||||
|
|
||||||
|
let timedOut = false;
|
||||||
|
const timer = timeout
|
||||||
|
? setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
child.kill();
|
||||||
|
}, timeout * 1000)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
child.stdout.on("data", onData);
|
||||||
|
child.stderr.on("data", onData);
|
||||||
|
child.on("error", (e: Error) => {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
reject(e);
|
||||||
|
});
|
||||||
|
|
||||||
|
const onAbort = () => child.kill();
|
||||||
|
signal?.addEventListener("abort", onAbort, { once: true });
|
||||||
|
|
||||||
|
child.on("close", (code: number) => {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
signal?.removeEventListener("abort", onAbort);
|
||||||
|
if (signal?.aborted) reject(new Error("aborted"));
|
||||||
|
else if (timedOut) reject(new Error(`timeout:${timeout}`));
|
||||||
|
else resolve({ exitCode: code });
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Extension ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
pi.registerFlag("ssh", {
|
||||||
|
description: "SSH remote: user@host or user@host:/remote/path",
|
||||||
|
type: "string",
|
||||||
|
});
|
||||||
|
|
||||||
|
const localCwd = process.cwd();
|
||||||
|
const localRead = createReadTool(localCwd);
|
||||||
|
const localWrite = createWriteTool(localCwd);
|
||||||
|
const localEdit = createEditTool(localCwd);
|
||||||
|
const localBash = createBashTool(localCwd);
|
||||||
|
|
||||||
|
// Resolved lazily in session_start once CLI flags are available
|
||||||
|
let resolvedSsh: {
|
||||||
|
remote: string;
|
||||||
|
remoteCwd: string;
|
||||||
|
socketPath: string;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
|
const getSsh = () => resolvedSsh;
|
||||||
|
|
||||||
|
// ── Tool overrides ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
...localRead,
|
||||||
|
async execute(id, params, signal, onUpdate, _ctx) {
|
||||||
|
const ssh = getSsh();
|
||||||
|
if (ssh) {
|
||||||
|
const tool = createReadTool(localCwd, {
|
||||||
|
operations: createRemoteReadOps(ssh.remote, ssh.socketPath, ssh.remoteCwd, localCwd),
|
||||||
|
});
|
||||||
|
return tool.execute(id, params, signal, onUpdate);
|
||||||
|
}
|
||||||
|
return localRead.execute(id, params, signal, onUpdate);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
...localWrite,
|
||||||
|
async execute(id, params, signal, onUpdate, _ctx) {
|
||||||
|
const ssh = getSsh();
|
||||||
|
if (ssh) {
|
||||||
|
const tool = createWriteTool(localCwd, {
|
||||||
|
operations: createRemoteWriteOps(ssh.remote, ssh.socketPath, ssh.remoteCwd, localCwd),
|
||||||
|
});
|
||||||
|
return tool.execute(id, params, signal, onUpdate);
|
||||||
|
}
|
||||||
|
return localWrite.execute(id, params, signal, onUpdate);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
...localEdit,
|
||||||
|
async execute(id, params, signal, onUpdate, _ctx) {
|
||||||
|
const ssh = getSsh();
|
||||||
|
if (ssh) {
|
||||||
|
const tool = createEditTool(localCwd, {
|
||||||
|
operations: createRemoteEditOps(ssh.remote, ssh.socketPath, ssh.remoteCwd, localCwd),
|
||||||
|
});
|
||||||
|
return tool.execute(id, params, signal, onUpdate);
|
||||||
|
}
|
||||||
|
return localEdit.execute(id, params, signal, onUpdate);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
...localBash,
|
||||||
|
async execute(id, params, signal, onUpdate, _ctx) {
|
||||||
|
const ssh = getSsh();
|
||||||
|
if (ssh) {
|
||||||
|
const tool = createBashTool(localCwd, {
|
||||||
|
operations: createRemoteBashOps(ssh.remote, ssh.socketPath, ssh.remoteCwd, localCwd),
|
||||||
|
});
|
||||||
|
return tool.execute(id, params, signal, onUpdate);
|
||||||
|
}
|
||||||
|
return localBash.execute(id, params, signal, onUpdate);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Session lifecycle ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pi.on("session_start", async (_event, ctx) => {
|
||||||
|
const arg = pi.getFlag("ssh") as string | undefined;
|
||||||
|
if (!arg) return;
|
||||||
|
|
||||||
|
let remote: string;
|
||||||
|
let remoteCwd: string;
|
||||||
|
|
||||||
|
if (arg.includes(":")) {
|
||||||
|
[remote, remoteCwd] = arg.split(":");
|
||||||
|
} else {
|
||||||
|
remote = arg;
|
||||||
|
// Resolve remote home dir via a plain one-shot SSH call before the
|
||||||
|
// ControlMaster is up — this is the only un-multiplexed connection.
|
||||||
|
const result = await new Promise<string>((resolve, reject) => {
|
||||||
|
const child = spawn("ssh", [remote, "pwd"], {
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
child.stdout.on("data", (d: Buffer) => chunks.push(d));
|
||||||
|
child.on("error", reject);
|
||||||
|
child.on("close", (code) => {
|
||||||
|
if (code === 0) resolve(Buffer.concat(chunks).toString().trim());
|
||||||
|
else reject(new Error(`Could not resolve remote pwd (exit ${code})`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
remoteCwd = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const socketPath = controlSocketPath();
|
||||||
|
ctx.ui.setStatus("ssh", ctx.ui.theme.fg("accent", `SSH: ${remote} ⟳ connecting…`));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await startControlMaster(remote, socketPath);
|
||||||
|
} catch (err) {
|
||||||
|
ctx.ui.setStatus("ssh", ctx.ui.theme.fg("error", `SSH: ${remote} ✗ failed`));
|
||||||
|
ctx.ui.notify(`SSH ControlMaster failed: ${err}`, "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolvedSsh = { remote, remoteCwd, socketPath };
|
||||||
|
ctx.ui.setStatus("ssh", ctx.ui.theme.fg("accent", `SSH ⚡ ${remote}:${remoteCwd}`));
|
||||||
|
ctx.ui.notify(`SSH ControlMaster ready — ${remote}:${remoteCwd}`, "success");
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.on("session_shutdown", async (_event, _ctx) => {
|
||||||
|
const ssh = getSsh();
|
||||||
|
if (ssh) {
|
||||||
|
await stopControlMaster(ssh.remote, ssh.socketPath);
|
||||||
|
resolvedSsh = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── User ! commands via SSH ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
pi.on("user_bash", (_event) => {
|
||||||
|
const ssh = getSsh();
|
||||||
|
if (!ssh) return;
|
||||||
|
return {
|
||||||
|
operations: createRemoteBashOps(ssh.remote, ssh.socketPath, ssh.remoteCwd, localCwd),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Tell the LLM where it's operating ───────────────────────────────────
|
||||||
|
|
||||||
|
pi.on("before_agent_start", async (event) => {
|
||||||
|
const ssh = getSsh();
|
||||||
|
if (ssh) {
|
||||||
|
const modified = event.systemPrompt.replace(
|
||||||
|
`Current working directory: ${localCwd}`,
|
||||||
|
`Current working directory: ${ssh.remoteCwd} (via SSH ControlMaster: ${ssh.remote})`,
|
||||||
|
);
|
||||||
|
return { systemPrompt: modified };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
Executable
+153
@@ -0,0 +1,153 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# install.sh — install pi-extensions
|
||||||
|
#
|
||||||
|
# Symlinks each extension in extensions/ into ~/.pi/agent/extensions/ so pi
|
||||||
|
# loads them automatically on every session. Idempotent and non-destructive.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./install.sh # install (interactive confirm)
|
||||||
|
# ./install.sh --yes # install without prompt
|
||||||
|
# ./install.sh --uninstall # remove symlinks that point into this repo
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||||
|
EXTENSIONS_SRC="${SCRIPT_DIR}/extensions"
|
||||||
|
EXTENSIONS_DEST="${HOME}/.pi/agent/extensions"
|
||||||
|
PI_AGENT_DIR="${HOME}/.pi/agent"
|
||||||
|
|
||||||
|
# ── helpers ──────────────────────────────────────────
|
||||||
|
ok() { printf ' \e[32m✓\e[0m %s\n' "$*"; }
|
||||||
|
note() { printf '==> %s\n' "$*"; }
|
||||||
|
warn() { printf ' \e[33m!\e[0m %s\n' "$*" >&2; }
|
||||||
|
err() { printf ' \e[31m✗\e[0m %s\n' "$*" >&2; }
|
||||||
|
|
||||||
|
confirm() {
|
||||||
|
[[ "$ASSUME_YES" == "yes" ]] && return 0
|
||||||
|
read -r -p "Proceed? [y/N] " ans
|
||||||
|
[[ "$ans" =~ ^[Yy]$ ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
link_into_repo() {
|
||||||
|
local target
|
||||||
|
[[ -L "$1" ]] || return 1
|
||||||
|
target=$(readlink -f "$1" 2>/dev/null || true)
|
||||||
|
[[ "$target" == "$SCRIPT_DIR"/* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
require_pi_installed() {
|
||||||
|
if [[ ! -d "$PI_AGENT_DIR" ]]; then
|
||||||
|
err "pi not detected at $PI_AGENT_DIR"
|
||||||
|
printf ' Install pi first: https://github.com/mariozechner/pi-coding-agent\n'
|
||||||
|
printf ' Re-run after `pi --help` (first run creates ~/.pi/agent/).\n'
|
||||||
|
exit 4
|
||||||
|
fi
|
||||||
|
mkdir -p "$EXTENSIONS_DEST"
|
||||||
|
ok "pi detected at $PI_AGENT_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── args ─────────────────────────────────────────────
|
||||||
|
ACTION="install"
|
||||||
|
ASSUME_YES="no"
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--uninstall) ACTION="uninstall"; shift ;;
|
||||||
|
-y|--yes) ASSUME_YES="yes"; shift ;;
|
||||||
|
-h|--help)
|
||||||
|
cat <<EOF
|
||||||
|
install.sh — install pi-extensions
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
./install.sh install (interactive confirm)
|
||||||
|
./install.sh --yes install without prompt
|
||||||
|
./install.sh --uninstall remove symlinks that point into this repo
|
||||||
|
|
||||||
|
Each .ts file in extensions/ is symlinked into ~/.pi/agent/extensions/.
|
||||||
|
Existing files that are already symlinked into this repo are left alone.
|
||||||
|
Existing real files or foreign symlinks are backed up with a timestamp.
|
||||||
|
EOF
|
||||||
|
exit 0 ;;
|
||||||
|
*) echo "Unknown flag: $1" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── install ──────────────────────────────────────────
|
||||||
|
do_install() {
|
||||||
|
echo
|
||||||
|
echo "pi-extensions installer"
|
||||||
|
echo "Repository: $SCRIPT_DIR"
|
||||||
|
echo
|
||||||
|
|
||||||
|
require_pi_installed
|
||||||
|
|
||||||
|
local extensions=("${EXTENSIONS_SRC}"/*.ts)
|
||||||
|
if [[ ! -e "${extensions[0]}" ]]; then
|
||||||
|
warn "No .ts files found in ${EXTENSIONS_SRC}/ — nothing to install."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Extensions to symlink into ${EXTENSIONS_DEST}/:"
|
||||||
|
for src in "${extensions[@]}"; do
|
||||||
|
printf ' %s\n' "$(basename "$src")"
|
||||||
|
done
|
||||||
|
echo
|
||||||
|
confirm || { echo "Aborted."; exit 0; }
|
||||||
|
echo
|
||||||
|
|
||||||
|
for src in "${extensions[@]}"; do
|
||||||
|
local name
|
||||||
|
name="$(basename "$src")"
|
||||||
|
local dest="${EXTENSIONS_DEST}/${name}"
|
||||||
|
|
||||||
|
note "Linking ${name}"
|
||||||
|
|
||||||
|
if [[ -e "$dest" || -L "$dest" ]]; then
|
||||||
|
if link_into_repo "$dest"; then
|
||||||
|
ok "${name} already linked"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
local backup="${dest}.bak.$(date +%Y%m%d-%H%M%S)"
|
||||||
|
mv "$dest" "$backup"
|
||||||
|
warn "Existing ${dest} backed up to ${backup}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
ln -s "$src" "$dest"
|
||||||
|
ok "Linked ${name} → ${src}"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
ok "Done. Reload pi with /reload or restart to pick up new extensions."
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── uninstall ────────────────────────────────────────
|
||||||
|
do_uninstall() {
|
||||||
|
echo
|
||||||
|
echo "pi-extensions uninstaller"
|
||||||
|
echo "Repository: $SCRIPT_DIR"
|
||||||
|
echo
|
||||||
|
confirm || { echo "Aborted."; exit 0; }
|
||||||
|
echo
|
||||||
|
|
||||||
|
local removed=0
|
||||||
|
for dest in "${EXTENSIONS_DEST}"/*.ts; do
|
||||||
|
[[ -e "$dest" || -L "$dest" ]] || continue
|
||||||
|
if link_into_repo "$dest"; then
|
||||||
|
rm "$dest"
|
||||||
|
ok "Removed $(basename "$dest")"
|
||||||
|
(( removed++ )) || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ $removed -eq 0 ]]; then
|
||||||
|
ok "No symlinks pointing into this repo found — nothing removed."
|
||||||
|
else
|
||||||
|
echo
|
||||||
|
ok "Done. Removed ${removed} symlink(s)."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$ACTION" in
|
||||||
|
install) do_install ;;
|
||||||
|
uninstall) do_uninstall ;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "pi-extensions",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Custom and modified pi coding-agent extensions",
|
||||||
|
"keywords": ["pi-package"],
|
||||||
|
"license": "MIT",
|
||||||
|
"pi": {
|
||||||
|
"extensions": ["./extensions"]
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user