Launch Week Day 3: Introducing Organization Notifications in Socket.Learn More
Socket
Book a DemoSign in
Socket

pi-extensions

Package Overview
Dependencies
Maintainers
1
Versions
23
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

pi-extensions - npm Package Compare versions

Comparing version
0.1.21
to
0.1.22
+86
.github/workflows/weather-native-bridge.yml
name: weather-native-bridge
on:
workflow_dispatch:
permissions:
contents: read
id-token: write
jobs:
build-prebuilt:
name: build (${{ matrix.target }})
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- runner: macos-14
target: aarch64-apple-darwin
- runner: macos-14
target: x86_64-apple-darwin
- runner: ubuntu-latest
target: x86_64-unknown-linux-gnu
- runner: windows-latest
target: x86_64-pc-windows-msvc
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Install bridge dependencies
working-directory: weather/native/weathr-bridge
run: npm install --no-package-lock
- name: Build prebuilt binary
working-directory: weather/native/weathr-bridge
run: npx napi build --platform --release --dts native.d.ts --target ${{ matrix.target }}
- name: Upload binary artifact
uses: actions/upload-artifact@v4
with:
name: bindings-${{ matrix.target }}
path: weather/native/weathr-bridge/pi_weather_bridge.*.node
if-no-files-found: error
publish-platform-packages:
name: publish prebuilt packages
runs-on: ubuntu-latest
needs: build-prebuilt
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
registry-url: https://registry.npmjs.org
- name: Download artifacts
uses: actions/download-artifact@v4
with:
path: weather/native/weathr-bridge/artifacts
- name: Prepare npm package folders
working-directory: weather
run: npm run native:prepare-packages
- name: Sync artifacts into npm package folders
working-directory: weather
run: npm run native:sync-artifacts
- name: Publish platform packages
working-directory: weather
run: npm run native:publish-packages
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
# Changelog
## 0.1.1 - 2026-02-12
- Added an embedded demo GIF in the README that links to the MP4 demo hosted on GitHub.
- Kept demo media out of npm installs while improving README preview on GitHub/npm.
## 0.1.0 - 2026-02-12
- Initial release of `/weather` weather widget extension.
- Added native Rust bridge (`native/weathr-bridge`) with automatic shell fallback.
- Added ANSI color preservation in the weather widget output.
- Fixed shell fallback PTY bootstrap under Bun by binding `script` stdin to `/dev/null` (avoids socket `tcgetattr` errors without stealing ESC input from Pi).
- `/weather-config` now warns when `location.auto=true` (which overrides manual latitude/longitude).
- Added optional dependency support for platform prebuilt native bridge packages (`@tmustier/pi-weather-bridge-*`).
- Added release automation workflow for publishing native bridge platform packages (`.github/workflows/weather-native-bridge.yml`).
- `/weather` now renders in the main custom UI area (above the editor) instead of centered overlay mode.
- Added `/weather-config` command and isolated config at `~/.pi/weather-widget/weathr/config.toml`.
import { spawn, type ChildProcess } from "node:child_process";
import { closeSync, constants as fsConstants, openSync } from "node:fs";
import { promises as fs } from "node:fs";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
import { matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
const WEATHER_COLUMNS = 100;
const WEATHER_ROWS = 30;
const WEATHER_STATUS_KEY = "weather-widget";
const WEATHER_CONFIG_HOME = path.join(os.homedir(), ".pi", "weather-widget");
const DEFAULT_WEATHER_CONFIG = `hide_hud = false
[location]
latitude = 52.5200
longitude = 13.4050
auto = true # set false to force the latitude/longitude above
hide = false
[units]
temperature = "celsius"
wind_speed = "kmh"
precipitation = "mm"
`;
const WEATHER_SIMULATION_CONDITIONS = new Set([
"clear",
"partly-cloudy",
"cloudy",
"overcast",
"fog",
"drizzle",
"rain",
"freezing-rain",
"rain-showers",
"snow",
"snow-grains",
"snow-showers",
"thunderstorm",
"thunderstorm-hail",
]);
type ParserMode = "normal" | "escape" | "csi" | "osc" | "osc_escape";
interface ParsedWeatherArgs {
forwardedArgs: string[];
ignoredTokens: string[];
}
interface WeatherWidgetOptions {
tui: { requestRender: () => void };
onClose: () => void;
scriptPath: string;
weathrPath: string;
weathrArgs: string[];
configHome: string;
columns: number;
rows: number;
}
interface NativeWeatherSnapshot {
stdout: string;
stderr: string;
exited: boolean;
exitCode?: number;
exitSignal?: string;
}
interface NativeWeatherProcess {
poll(): NativeWeatherSnapshot;
writeInput(input: string): boolean;
stop(): void;
}
interface NativeWeatherBridgeModule {
NativeWeatherProcess: new (
scriptPath: string,
weathrPath: string,
args: string[],
configHome: string,
columns: number,
rows: number,
) => NativeWeatherProcess;
}
const NATIVE_POLL_INTERVAL_MS = 33;
const NATIVE_STARTUP_TIMEOUT_MS = 1500;
const require = createRequire(import.meta.url);
let nativeWeatherBridgeModule: NativeWeatherBridgeModule | null | undefined;
let nativeWeatherBridgeLoadError: unknown | null = null;
function isNativeWeatherBridgeModule(value: unknown): value is NativeWeatherBridgeModule {
if (typeof value !== "object" || value === null) {
return false;
}
const constructorValue = Reflect.get(value, "NativeWeatherProcess");
return typeof constructorValue === "function";
}
function getNativeWeatherBridgeModule(): NativeWeatherBridgeModule | null {
if (nativeWeatherBridgeModule !== undefined) {
return nativeWeatherBridgeModule;
}
try {
const loaded: unknown = require("./native/weathr-bridge/index.js");
if (!isNativeWeatherBridgeModule(loaded)) {
nativeWeatherBridgeLoadError = new Error("Invalid native weather bridge module shape");
nativeWeatherBridgeModule = null;
return nativeWeatherBridgeModule;
}
nativeWeatherBridgeLoadError = null;
nativeWeatherBridgeModule = loaded;
} catch (error) {
nativeWeatherBridgeLoadError = error;
nativeWeatherBridgeModule = null;
}
return nativeWeatherBridgeModule;
}
interface ScreenCell {
character: string;
style: string;
}
function createBlankCell(): ScreenCell {
return {
character: " ",
style: "",
};
}
class AnsiScreenBuffer {
private readonly cells: ScreenCell[][];
private row = 0;
private col = 0;
private mode: ParserMode = "normal";
private csiBuffer = "";
private currentStyle = "";
private readonly formatTokens = new Set<string>();
private foregroundToken: string | null = null;
private backgroundToken: string | null = null;
constructor(
private readonly columns: number,
private readonly rows: number,
) {
this.cells = Array.from({ length: rows }, () =>
Array.from({ length: columns }, () => createBlankCell()),
);
}
clear(): void {
for (let row = 0; row < this.rows; row += 1) {
const currentRow = this.cells[row];
if (!currentRow) continue;
for (let col = 0; col < this.columns; col += 1) {
const cell = currentRow[col];
if (!cell) continue;
cell.character = " ";
cell.style = "";
}
}
this.row = 0;
this.col = 0;
this.mode = "normal";
this.csiBuffer = "";
this.resetStyleState();
}
feed(chunk: string): void {
for (const character of chunk) {
this.consume(character);
}
}
getLines(): string[] {
return this.cells.map((line) => this.renderLine(line));
}
private renderLine(line: ScreenCell[]): string {
let lastVisibleIndex = -1;
for (let index = line.length - 1; index >= 0; index -= 1) {
const cell = line[index];
if (cell && cell.character !== " ") {
lastVisibleIndex = index;
break;
}
}
if (lastVisibleIndex < 0) {
return "";
}
let output = "";
let activeStyle = "";
for (let index = 0; index <= lastVisibleIndex; index += 1) {
const cell = line[index];
if (!cell) {
continue;
}
if (cell.style !== activeStyle) {
if (cell.style.length === 0) {
if (activeStyle.length > 0) {
output += "\u001b[0m";
}
} else {
output += cell.style;
}
activeStyle = cell.style;
}
output += cell.character;
}
if (activeStyle.length > 0) {
output += "\u001b[0m";
}
return output;
}
private consume(character: string): void {
switch (this.mode) {
case "normal":
this.consumeNormal(character);
return;
case "escape":
this.consumeEscape(character);
return;
case "csi":
this.consumeCsi(character);
return;
case "osc":
this.consumeOsc(character);
return;
case "osc_escape":
this.consumeOscEscape(character);
return;
}
}
private consumeNormal(character: string): void {
if (character === "\u001b") {
this.mode = "escape";
return;
}
if (character === "\n") {
this.row = Math.min(this.rows - 1, this.row + 1);
return;
}
if (character === "\r") {
this.col = 0;
return;
}
if (character === "\b") {
this.col = Math.max(0, this.col - 1);
return;
}
if (character === "\t") {
const tabWidth = 4;
const targetCol = Math.min(this.columns - 1, this.col + (tabWidth - (this.col % tabWidth)));
while (this.col < targetCol) {
this.writeChar(" ");
}
return;
}
const codePoint = character.codePointAt(0);
if (codePoint === undefined || codePoint < 0x20) {
return;
}
this.writeChar(character);
}
private consumeEscape(character: string): void {
if (character === "[") {
this.mode = "csi";
this.csiBuffer = "";
return;
}
if (character === "]") {
this.mode = "osc";
return;
}
this.mode = "normal";
}
private consumeCsi(character: string): void {
if (!this.isFinalCsiCharacter(character)) {
this.csiBuffer += character;
return;
}
this.applyCsi(this.csiBuffer, character);
this.mode = "normal";
this.csiBuffer = "";
}
private consumeOsc(character: string): void {
if (character === "\u0007") {
this.mode = "normal";
return;
}
if (character === "\u001b") {
this.mode = "osc_escape";
}
}
private consumeOscEscape(character: string): void {
if (character === "\\") {
this.mode = "normal";
return;
}
this.mode = "osc";
}
private applyCsi(sequence: string, finalChar: string): void {
switch (finalChar) {
case "H":
case "f": {
const [rowRaw, colRaw] = sequence.split(";");
const targetRow = this.parseCsiNumber(rowRaw, 1) - 1;
const targetCol = this.parseCsiNumber(colRaw, 1) - 1;
this.row = this.clamp(targetRow, 0, this.rows - 1);
this.col = this.clamp(targetCol, 0, this.columns - 1);
return;
}
case "A": {
const amount = this.parseCsiNumber(sequence, 1);
this.row = this.clamp(this.row - amount, 0, this.rows - 1);
return;
}
case "B": {
const amount = this.parseCsiNumber(sequence, 1);
this.row = this.clamp(this.row + amount, 0, this.rows - 1);
return;
}
case "C": {
const amount = this.parseCsiNumber(sequence, 1);
this.col = this.clamp(this.col + amount, 0, this.columns - 1);
return;
}
case "D": {
const amount = this.parseCsiNumber(sequence, 1);
this.col = this.clamp(this.col - amount, 0, this.columns - 1);
return;
}
case "J": {
this.eraseDisplay(this.parseCsiNumber(sequence, 0));
return;
}
case "K": {
this.eraseLine(this.parseCsiNumber(sequence, 0));
return;
}
case "h": {
if (sequence === "?1049") {
this.clear();
}
return;
}
case "l": {
if (sequence === "?1049") {
this.clear();
}
return;
}
case "m": {
this.applySgr(sequence);
return;
}
default:
return;
}
}
private applySgr(sequence: string): void {
const tokens = sequence.length === 0
? ["0"]
: sequence
.split(";")
.map((token) => token.trim())
.filter((token) => token.length > 0);
if (tokens.length === 0) {
this.resetStyleState();
return;
}
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index];
const code = Number.parseInt(token, 10);
if (Number.isNaN(code)) {
continue;
}
if (code === 0) {
this.resetStyleState();
continue;
}
if (code >= 1 && code <= 9) {
this.formatTokens.add(String(code));
continue;
}
if (code === 22) {
this.formatTokens.delete("1");
this.formatTokens.delete("2");
continue;
}
if (code === 23) {
this.formatTokens.delete("3");
continue;
}
if (code === 24) {
this.formatTokens.delete("4");
continue;
}
if (code === 25) {
this.formatTokens.delete("5");
continue;
}
if (code === 27) {
this.formatTokens.delete("7");
continue;
}
if (code === 28) {
this.formatTokens.delete("8");
continue;
}
if (code === 29) {
this.formatTokens.delete("9");
continue;
}
if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) {
this.foregroundToken = String(code);
continue;
}
if (code === 39) {
this.foregroundToken = null;
continue;
}
if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) {
this.backgroundToken = String(code);
continue;
}
if (code === 49) {
this.backgroundToken = null;
continue;
}
if (code === 38 || code === 48) {
const mode = tokens[index + 1];
if (mode === "5") {
const value = tokens[index + 2];
if (value) {
const tokenValue = `${code};5;${value}`;
if (code === 38) {
this.foregroundToken = tokenValue;
} else {
this.backgroundToken = tokenValue;
}
index += 2;
}
continue;
}
if (mode === "2") {
const r = tokens[index + 2];
const g = tokens[index + 3];
const b = tokens[index + 4];
if (r && g && b) {
const tokenValue = `${code};2;${r};${g};${b}`;
if (code === 38) {
this.foregroundToken = tokenValue;
} else {
this.backgroundToken = tokenValue;
}
index += 4;
}
}
}
}
this.rebuildCurrentStyle();
}
private resetStyleState(): void {
this.formatTokens.clear();
this.foregroundToken = null;
this.backgroundToken = null;
this.currentStyle = "";
}
private rebuildCurrentStyle(): void {
const orderedFormats = ["1", "2", "3", "4", "5", "7", "8", "9"]
.filter((token) => this.formatTokens.has(token));
const tokens = [...orderedFormats];
if (this.foregroundToken) {
tokens.push(this.foregroundToken);
}
if (this.backgroundToken) {
tokens.push(this.backgroundToken);
}
this.currentStyle = tokens.length === 0 ? "" : `\u001b[${tokens.join(";")}m`;
}
private eraseDisplay(mode: number): void {
if (mode === 2 || mode === 3) {
this.clear();
return;
}
if (mode === 0) {
for (let row = this.row; row < this.rows; row += 1) {
const currentRow = this.cells[row];
if (!currentRow) continue;
const startCol = row === this.row ? this.col : 0;
for (let col = startCol; col < this.columns; col += 1) {
const cell = currentRow[col];
if (!cell) continue;
cell.character = " ";
cell.style = "";
}
}
return;
}
if (mode === 1) {
for (let row = 0; row <= this.row; row += 1) {
const currentRow = this.cells[row];
if (!currentRow) continue;
const endCol = row === this.row ? this.col : this.columns - 1;
for (let col = 0; col <= endCol; col += 1) {
const cell = currentRow[col];
if (!cell) continue;
cell.character = " ";
cell.style = "";
}
}
}
}
private eraseLine(mode: number): void {
const currentRow = this.cells[this.row];
if (!currentRow) return;
if (mode === 2) {
for (let col = 0; col < this.columns; col += 1) {
const cell = currentRow[col];
if (!cell) continue;
cell.character = " ";
cell.style = "";
}
return;
}
if (mode === 1) {
for (let col = 0; col <= this.col; col += 1) {
const cell = currentRow[col];
if (!cell) continue;
cell.character = " ";
cell.style = "";
}
return;
}
for (let col = this.col; col < this.columns; col += 1) {
const cell = currentRow[col];
if (!cell) continue;
cell.character = " ";
cell.style = "";
}
}
private writeChar(character: string): void {
const currentRow = this.cells[this.row];
if (!currentRow) return;
const cell = currentRow[this.col];
if (!cell) return;
cell.character = character;
cell.style = this.currentStyle;
this.col += 1;
if (this.col >= this.columns) {
this.col = 0;
if (this.row < this.rows - 1) {
this.row += 1;
}
}
}
private parseCsiNumber(raw: string | undefined, fallback: number): number {
if (!raw || raw.length === 0) {
return fallback;
}
const normalized = raw.replace(/^\?/u, "");
const parsed = Number.parseInt(normalized, 10);
if (Number.isNaN(parsed)) {
return fallback;
}
return parsed;
}
private isFinalCsiCharacter(character: string): boolean {
const codePoint = character.codePointAt(0);
if (codePoint === undefined) {
return false;
}
return codePoint >= 0x40 && codePoint <= 0x7e;
}
private clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
}
class WeatherWidgetComponent {
private readonly screen: AnsiScreenBuffer;
private process: ChildProcess | null = null;
private nativeProcess: NativeWeatherProcess | null = null;
private nativePollHandle: ReturnType<typeof setInterval> | null = null;
private nativeStartupTimeout: ReturnType<typeof setTimeout> | null = null;
private hasOutput = false;
private lastNotice: string | undefined;
private readonly expectedExitPids = new Set<number>();
private activeRunId = 0;
private nativeFallbackWarned = false;
constructor(private readonly options: WeatherWidgetOptions) {
this.screen = new AnsiScreenBuffer(options.columns, options.rows);
this.startProcess();
}
handleInput(data: string): void {
if (matchesKey(data, "escape") || data === "q" || data === "Q") {
this.dispose();
this.options.onClose();
return;
}
if (data === "r" || data === "R") {
this.restart();
}
}
render(width: number): string[] {
if (!this.hasOutput) {
if (this.lastNotice) {
return [truncateToWidth(this.lastNotice, width)];
}
return [truncateToWidth("Starting weather widget...", width)];
}
const lines = this.screen.getLines().map((line) => truncateToWidth(line, width));
if (this.lastNotice) {
lines.push(truncateToWidth(this.lastNotice, width));
}
return lines;
}
invalidate(): void {}
private consumeStdout(output: string): boolean {
if (output.length === 0) {
return false;
}
this.clearNativeStartupTimeout();
this.screen.feed(output);
this.hasOutput = true;
return true;
}
private consumeStderr(output: string): boolean {
const message = output.trim();
if (message.length === 0) {
return false;
}
this.lastNotice = message;
return true;
}
private setExitNotice(reason: string): void {
this.lastNotice = `weathr exited (${reason}). Press R to restart.`;
}
dispose(): void {
this.activeRunId += 1;
this.stopNativeProcess();
this.stopScriptProcess();
}
private restart(): void {
this.dispose();
this.screen.clear();
this.hasOutput = false;
this.lastNotice = undefined;
this.startProcess();
this.options.tui.requestRender();
}
private shouldUseNativeBridge(): boolean {
return process.env.PI_WEATHER_NATIVE !== "0";
}
private startProcess(): void {
const runId = this.activeRunId + 1;
this.activeRunId = runId;
const nativeModule = getNativeWeatherBridgeModule();
if (this.shouldUseNativeBridge() && nativeModule && this.startNativeProcess(nativeModule, runId)) {
return;
}
if (!this.nativeFallbackWarned && nativeWeatherBridgeLoadError) {
this.nativeFallbackWarned = true;
this.lastNotice = "Native weather bridge unavailable. Using shell fallback.";
}
this.startScriptProcess(runId);
}
private startNativeProcess(nativeModule: NativeWeatherBridgeModule, runId: number): boolean {
try {
this.nativeProcess = new nativeModule.NativeWeatherProcess(
this.options.scriptPath,
this.options.weathrPath,
this.options.weathrArgs,
this.options.configHome,
this.options.columns,
this.options.rows,
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.lastNotice = `Native weather bridge failed: ${message}. Using shell fallback.`;
this.nativeProcess = null;
return false;
}
this.clearNativePollHandle();
this.nativePollHandle = setInterval(() => {
this.pollNativeProcess(runId);
}, NATIVE_POLL_INTERVAL_MS);
this.scheduleNativeStartupFallback(runId);
return true;
}
private pollNativeProcess(runId: number): void {
if (runId !== this.activeRunId) {
return;
}
const process = this.nativeProcess;
if (!process) {
return;
}
let snapshot: NativeWeatherSnapshot;
try {
snapshot = process.poll();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.clearNativePollHandle();
this.clearNativeStartupTimeout();
this.nativeProcess = null;
this.lastNotice = `Native weather bridge crashed: ${message}. Press R to restart.`;
this.options.tui.requestRender();
return;
}
const renderedStdout = this.consumeStdout(snapshot.stdout);
const renderedStderr = this.consumeStderr(snapshot.stderr);
let renderedExit = false;
if (snapshot.exited) {
this.clearNativePollHandle();
this.clearNativeStartupTimeout();
this.nativeProcess = null;
const reason = this.formatNativeExitReason(snapshot);
this.setExitNotice(reason);
renderedExit = true;
}
if (renderedStdout || renderedStderr || renderedExit) {
this.options.tui.requestRender();
}
}
private formatNativeExitReason(snapshot: NativeWeatherSnapshot): string {
if (typeof snapshot.exitCode === "number") {
return `code ${snapshot.exitCode}`;
}
if (snapshot.exitSignal && snapshot.exitSignal.length > 0) {
return `signal ${snapshot.exitSignal}`;
}
return "unknown";
}
private clearNativePollHandle(): void {
if (!this.nativePollHandle) {
return;
}
clearInterval(this.nativePollHandle);
this.nativePollHandle = null;
}
private scheduleNativeStartupFallback(runId: number): void {
this.clearNativeStartupTimeout();
this.nativeStartupTimeout = setTimeout(() => {
if (runId !== this.activeRunId) {
return;
}
if (this.hasOutput) {
return;
}
if (!this.nativeProcess) {
return;
}
this.stopNativeProcess();
this.lastNotice = "Native weather bridge produced no output. Falling back to shell bridge.";
this.startScriptProcess(runId);
this.options.tui.requestRender();
}, NATIVE_STARTUP_TIMEOUT_MS);
}
private clearNativeStartupTimeout(): void {
if (!this.nativeStartupTimeout) {
return;
}
clearTimeout(this.nativeStartupTimeout);
this.nativeStartupTimeout = null;
}
private stopNativeProcess(): void {
this.clearNativePollHandle();
this.clearNativeStartupTimeout();
const nativeProcess = this.nativeProcess;
this.nativeProcess = null;
if (!nativeProcess) {
return;
}
try {
nativeProcess.writeInput("q");
} catch {
// Best effort.
}
try {
nativeProcess.stop();
} catch {
// Best effort.
}
}
private startScriptProcess(runId: number): void {
const escapedBinary = shellQuote(this.options.weathrPath);
const escapedArgs = this.options.weathrArgs.map(shellQuote).join(" ");
const weatherCommand = escapedArgs.length > 0 ? `${escapedBinary} ${escapedArgs}` : escapedBinary;
const shellCommand = `stty cols ${this.options.columns} rows ${this.options.rows}; exec ${weatherCommand}`;
const scriptStdin = resolveScriptStdin();
let child: ChildProcess;
try {
child = spawn(this.options.scriptPath, ["-q", "/dev/null", "sh", "-c", shellCommand], {
env: createWeatherEnv(this.options.configHome),
stdio: [scriptStdin, "pipe", "pipe"],
});
} catch (error) {
if (typeof scriptStdin === "number") {
try {
closeSync(scriptStdin);
} catch {
// Best effort.
}
}
const message = error instanceof Error ? error.message : String(error);
this.lastNotice = `Failed to start weathr: ${message}`;
this.options.tui.requestRender();
return;
}
if (typeof scriptStdin === "number") {
try {
closeSync(scriptStdin);
} catch {
// Best effort.
}
}
if (!child.stdout || !child.stderr) {
this.lastNotice = "Failed to start weathr: missing stdio streams.";
this.options.tui.requestRender();
try {
child.kill("SIGTERM");
} catch {
// Best effort.
}
return;
}
this.process = child;
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk: string | Buffer) => {
if (runId !== this.activeRunId) {
return;
}
const output = typeof chunk === "string" ? chunk : chunk.toString("utf8");
if (this.consumeStdout(output)) {
this.options.tui.requestRender();
}
});
child.stderr.on("data", (chunk: string | Buffer) => {
if (runId !== this.activeRunId) {
return;
}
const output = typeof chunk === "string" ? chunk : chunk.toString("utf8");
if (this.consumeStderr(output)) {
this.options.tui.requestRender();
}
});
child.on("error", (error: Error) => {
if (runId !== this.activeRunId) {
return;
}
this.process = null;
this.lastNotice = `Failed to start weathr: ${error.message}`;
this.options.tui.requestRender();
});
child.on("exit", (code: number | null, signal: NodeJS.Signals | null) => {
if (child.pid !== undefined && this.expectedExitPids.delete(child.pid)) {
if (runId === this.activeRunId) {
this.process = null;
}
return;
}
if (runId !== this.activeRunId) {
return;
}
this.process = null;
const reason = code !== null ? `code ${code}` : `signal ${signal ?? "unknown"}`;
this.setExitNotice(reason);
this.options.tui.requestRender();
});
}
private stopScriptProcess(): void {
const activeProcess = this.process;
this.process = null;
if (!activeProcess) {
return;
}
if (activeProcess.pid !== undefined) {
this.expectedExitPids.add(activeProcess.pid);
}
try {
if (activeProcess.stdin && activeProcess.stdin.writable) {
activeProcess.stdin.write("q");
}
} catch {
// Best effort.
}
setTimeout(() => {
if (!activeProcess.killed) {
activeProcess.kill("SIGTERM");
}
}, 100);
}
}
function resolveScriptStdin(): "pipe" | number {
try {
return openSync("/dev/null", fsConstants.O_RDONLY);
} catch {
return "pipe";
}
}
function createWeatherEnv(configHome: string): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {
...process.env,
XDG_CONFIG_HOME: configHome,
};
if ("NO_COLOR" in env) {
delete env.NO_COLOR;
}
if (!env.COLORTERM || env.COLORTERM.length === 0) {
env.COLORTERM = "truecolor";
}
if (!env.TERM || env.TERM.length === 0) {
env.TERM = "xterm-256color";
}
return env;
}
function shellQuote(value: string): string {
return `'${value.split("'").join(`'"'"'`)}'`;
}
async function ensureWeatherConfig(configHome: string): Promise<string> {
const configDir = path.join(configHome, "weathr");
const configPath = path.join(configDir, "config.toml");
try {
await fs.access(configPath, fsConstants.F_OK);
return configPath;
} catch {
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(configPath, DEFAULT_WEATHER_CONFIG, "utf8");
return configPath;
}
}
async function isExecutable(filePath: string): Promise<boolean> {
try {
await fs.access(filePath, fsConstants.X_OK);
return true;
} catch {
return false;
}
}
function collectPathExecutables(binaryName: string): string[] {
const pathValue = process.env.PATH;
if (!pathValue) {
return [];
}
const pathEntries = pathValue
.split(path.delimiter)
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
return pathEntries.map((entry) => path.join(entry, binaryName));
}
async function resolveExecutable(binaryName: string, extraCandidates: string[]): Promise<string | null> {
const candidates = [...collectPathExecutables(binaryName), ...extraCandidates];
for (const candidate of candidates) {
if (candidate.length === 0) continue;
if (await isExecutable(candidate)) {
return candidate;
}
}
return null;
}
interface WeatherConfigSummary {
auto: boolean | null;
latitude: number | null;
longitude: number | null;
}
function summarizeWeatherConfig(configText: string): WeatherConfigSummary {
let inLocationSection = false;
let auto: boolean | null = null;
let latitude: number | null = null;
let longitude: number | null = null;
for (const rawLine of configText.split(/\r?\n/u)) {
const lineWithoutComment = rawLine.split("#")[0];
if (!lineWithoutComment) {
continue;
}
const line = lineWithoutComment.trim();
if (line.length === 0) {
continue;
}
if (line.startsWith("[") && line.endsWith("]")) {
inLocationSection = line === "[location]";
continue;
}
if (!inLocationSection) {
continue;
}
const separatorIndex = line.indexOf("=");
if (separatorIndex < 0) {
continue;
}
const key = line.slice(0, separatorIndex).trim();
const rawValue = line.slice(separatorIndex + 1).trim();
switch (key) {
case "auto": {
if (rawValue === "true") {
auto = true;
} else if (rawValue === "false") {
auto = false;
}
break;
}
case "latitude": {
const parsed = Number.parseFloat(rawValue);
if (!Number.isNaN(parsed)) {
latitude = parsed;
}
break;
}
case "longitude": {
const parsed = Number.parseFloat(rawValue);
if (!Number.isNaN(parsed)) {
longitude = parsed;
}
break;
}
default:
break;
}
}
return {
auto,
latitude,
longitude,
};
}
function parseWeatherArgs(rawArgs: string | undefined): ParsedWeatherArgs {
const trimmed = rawArgs?.trim();
if (!trimmed || trimmed.length === 0) {
return { forwardedArgs: [], ignoredTokens: [] };
}
const tokens = trimmed
.split(/\s+/u)
.map((token) => token.trim().toLowerCase())
.filter((token) => token.length > 0);
if (tokens.length === 1) {
const onlyToken = tokens[0];
if (onlyToken && WEATHER_SIMULATION_CONDITIONS.has(onlyToken)) {
return {
forwardedArgs: ["--simulate", onlyToken],
ignoredTokens: [],
};
}
}
const forwardedArgs: string[] = [];
const ignoredTokens: string[] = [];
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index];
if (!token) continue;
if (WEATHER_SIMULATION_CONDITIONS.has(token)) {
forwardedArgs.push("--simulate", token);
continue;
}
switch (token) {
case "simulate":
case "--simulate": {
const condition = tokens[index + 1];
if (condition && WEATHER_SIMULATION_CONDITIONS.has(condition)) {
forwardedArgs.push("--simulate", condition);
index += 1;
} else {
ignoredTokens.push(token);
}
break;
}
case "night":
case "--night":
forwardedArgs.push("--night");
break;
case "leaves":
case "--leaves":
forwardedArgs.push("--leaves");
break;
case "auto-location":
case "--auto-location":
forwardedArgs.push("--auto-location");
break;
case "hide-location":
case "--hide-location":
forwardedArgs.push("--hide-location");
break;
case "hide-hud":
case "--hide-hud":
forwardedArgs.push("--hide-hud");
break;
case "imperial":
case "--imperial":
forwardedArgs.push("--imperial");
break;
case "metric":
case "--metric":
forwardedArgs.push("--metric");
break;
default:
ignoredTokens.push(token);
break;
}
}
return { forwardedArgs, ignoredTokens };
}
async function openWeatherWidget(args: string | undefined, ctx: ExtensionCommandContext): Promise<void> {
if (!ctx.hasUI) {
ctx.ui.notify("/weather requires interactive mode", "error");
return;
}
const scriptPath = await resolveExecutable("script", ["/usr/bin/script"]);
if (!scriptPath) {
ctx.ui.notify("Missing `script` command. Install util-linux (Linux) or use macOS default.", "error");
return;
}
const weathrPath = await resolveExecutable("weathr", [
path.join(os.homedir(), ".cargo", "bin", "weathr"),
"/opt/homebrew/bin/weathr",
"/usr/local/bin/weathr",
]);
if (!weathrPath) {
ctx.ui.notify("`weathr` is not installed. Run: cargo install weathr", "error");
return;
}
try {
await ensureWeatherConfig(WEATHER_CONFIG_HOME);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
ctx.ui.notify(`Failed to create weather config: ${message}`, "error");
return;
}
const parsed = parseWeatherArgs(args);
if (parsed.ignoredTokens.length > 0) {
ctx.ui.notify(`Ignored args: ${parsed.ignoredTokens.join(", ")}`, "warning");
}
ctx.ui.setStatus(WEATHER_STATUS_KEY, "ESC/Q close • R restart");
let component: WeatherWidgetComponent | null = null;
try {
await ctx.ui.custom((tui, _theme, _keybindings, done) => {
component = new WeatherWidgetComponent({
tui,
onClose: () => done(undefined),
scriptPath,
weathrPath,
weathrArgs: parsed.forwardedArgs,
configHome: WEATHER_CONFIG_HOME,
columns: WEATHER_COLUMNS,
rows: WEATHER_ROWS,
});
return component;
});
} finally {
component?.dispose();
ctx.ui.setStatus(WEATHER_STATUS_KEY, undefined);
}
}
async function editWeatherConfig(ctx: ExtensionCommandContext): Promise<void> {
if (!ctx.hasUI) {
ctx.ui.notify("/weather-config requires interactive mode", "error");
return;
}
const configPath = await ensureWeatherConfig(WEATHER_CONFIG_HOME);
let currentConfig = DEFAULT_WEATHER_CONFIG;
try {
currentConfig = await fs.readFile(configPath, "utf8");
} catch {
currentConfig = DEFAULT_WEATHER_CONFIG;
}
const edited = await ctx.ui.editor("weathr config.toml", currentConfig);
if (edited === undefined) {
return;
}
await fs.writeFile(configPath, edited, "utf8");
ctx.ui.notify(`Saved ${configPath}`, "info");
const summary = summarizeWeatherConfig(edited);
if (summary.auto === true && summary.latitude !== null && summary.longitude !== null) {
ctx.ui.notify(
"location.auto=true overrides latitude/longitude. Set auto=false to use your coordinates.",
"warning",
);
}
}
export default function weatherExtension(pi: ExtensionAPI): void {
pi.registerCommand("weather", {
description: "Open live weather widget (Esc/Q close, R restart)",
handler: async (args, ctx) => {
await openWeatherWidget(args, ctx);
},
});
pi.registerCommand("weather-config", {
description: "Edit weather widget config.toml",
handler: async (_args, ctx) => {
await editWeatherConfig(ctx);
},
});
}
MIT License
Copyright (c) 2026 Thomas Mustier
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.
fn main() {
napi_build::setup();
}
[package]
name = "pi_weather_bridge"
version = "0.1.0"
edition = "2021"
publish = false
[lib]
crate-type = ["cdylib"]
[dependencies]
napi = { version = "2", features = ["napi8"] }
napi-derive = "2"
[build-dependencies]
napi-build = "2"
export interface WeatherProcessSnapshot {
stdout: string;
stderr: string;
exited: boolean;
exitCode?: number;
exitSignal?: string;
}
export declare class NativeWeatherProcess {
constructor(
scriptPath: string,
weathrPath: string,
args: string[],
configHome: string,
columns: number,
rows: number,
);
poll(): WeatherProcessSnapshot;
writeInput(input: string): boolean;
stop(): void;
restart(): void;
resize(columns: number, rows: number): void;
isRunning(): boolean;
}
/* tslint:disable */
/* eslint-disable */
/* prettier-ignore */
/* auto-generated by NAPI-RS */
const { existsSync, readFileSync } = require('fs')
const { join } = require('path')
const { platform, arch } = process
let nativeBinding = null
let localFileExisted = false
let loadError = null
function isMusl() {
// For Node 10
if (!process.report || typeof process.report.getReport !== 'function') {
try {
const lddPath = require('child_process').execSync('which ldd').toString().trim()
return readFileSync(lddPath, 'utf8').includes('musl')
} catch (e) {
return true
}
} else {
const { glibcVersionRuntime } = process.report.getReport().header
return !glibcVersionRuntime
}
}
switch (platform) {
case 'android':
switch (arch) {
case 'arm64':
localFileExisted = existsSync(join(__dirname, 'pi_weather_bridge.android-arm64.node'))
try {
if (localFileExisted) {
nativeBinding = require('./pi_weather_bridge.android-arm64.node')
} else {
nativeBinding = require('@tmustier/pi-weather-bridge-android-arm64')
}
} catch (e) {
loadError = e
}
break
case 'arm':
localFileExisted = existsSync(join(__dirname, 'pi_weather_bridge.android-arm-eabi.node'))
try {
if (localFileExisted) {
nativeBinding = require('./pi_weather_bridge.android-arm-eabi.node')
} else {
nativeBinding = require('@tmustier/pi-weather-bridge-android-arm-eabi')
}
} catch (e) {
loadError = e
}
break
default:
throw new Error(`Unsupported architecture on Android ${arch}`)
}
break
case 'win32':
switch (arch) {
case 'x64':
localFileExisted = existsSync(
join(__dirname, 'pi_weather_bridge.win32-x64-msvc.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./pi_weather_bridge.win32-x64-msvc.node')
} else {
nativeBinding = require('@tmustier/pi-weather-bridge-win32-x64-msvc')
}
} catch (e) {
loadError = e
}
break
case 'ia32':
localFileExisted = existsSync(
join(__dirname, 'pi_weather_bridge.win32-ia32-msvc.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./pi_weather_bridge.win32-ia32-msvc.node')
} else {
nativeBinding = require('@tmustier/pi-weather-bridge-win32-ia32-msvc')
}
} catch (e) {
loadError = e
}
break
case 'arm64':
localFileExisted = existsSync(
join(__dirname, 'pi_weather_bridge.win32-arm64-msvc.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./pi_weather_bridge.win32-arm64-msvc.node')
} else {
nativeBinding = require('@tmustier/pi-weather-bridge-win32-arm64-msvc')
}
} catch (e) {
loadError = e
}
break
default:
throw new Error(`Unsupported architecture on Windows: ${arch}`)
}
break
case 'darwin':
localFileExisted = existsSync(join(__dirname, 'pi_weather_bridge.darwin-universal.node'))
try {
if (localFileExisted) {
nativeBinding = require('./pi_weather_bridge.darwin-universal.node')
} else {
nativeBinding = require('@tmustier/pi-weather-bridge-darwin-universal')
}
break
} catch {}
switch (arch) {
case 'x64':
localFileExisted = existsSync(join(__dirname, 'pi_weather_bridge.darwin-x64.node'))
try {
if (localFileExisted) {
nativeBinding = require('./pi_weather_bridge.darwin-x64.node')
} else {
nativeBinding = require('@tmustier/pi-weather-bridge-darwin-x64')
}
} catch (e) {
loadError = e
}
break
case 'arm64':
localFileExisted = existsSync(
join(__dirname, 'pi_weather_bridge.darwin-arm64.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./pi_weather_bridge.darwin-arm64.node')
} else {
nativeBinding = require('@tmustier/pi-weather-bridge-darwin-arm64')
}
} catch (e) {
loadError = e
}
break
default:
throw new Error(`Unsupported architecture on macOS: ${arch}`)
}
break
case 'freebsd':
if (arch !== 'x64') {
throw new Error(`Unsupported architecture on FreeBSD: ${arch}`)
}
localFileExisted = existsSync(join(__dirname, 'pi_weather_bridge.freebsd-x64.node'))
try {
if (localFileExisted) {
nativeBinding = require('./pi_weather_bridge.freebsd-x64.node')
} else {
nativeBinding = require('@tmustier/pi-weather-bridge-freebsd-x64')
}
} catch (e) {
loadError = e
}
break
case 'linux':
switch (arch) {
case 'x64':
if (isMusl()) {
localFileExisted = existsSync(
join(__dirname, 'pi_weather_bridge.linux-x64-musl.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./pi_weather_bridge.linux-x64-musl.node')
} else {
nativeBinding = require('@tmustier/pi-weather-bridge-linux-x64-musl')
}
} catch (e) {
loadError = e
}
} else {
localFileExisted = existsSync(
join(__dirname, 'pi_weather_bridge.linux-x64-gnu.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./pi_weather_bridge.linux-x64-gnu.node')
} else {
nativeBinding = require('@tmustier/pi-weather-bridge-linux-x64-gnu')
}
} catch (e) {
loadError = e
}
}
break
case 'arm64':
if (isMusl()) {
localFileExisted = existsSync(
join(__dirname, 'pi_weather_bridge.linux-arm64-musl.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./pi_weather_bridge.linux-arm64-musl.node')
} else {
nativeBinding = require('@tmustier/pi-weather-bridge-linux-arm64-musl')
}
} catch (e) {
loadError = e
}
} else {
localFileExisted = existsSync(
join(__dirname, 'pi_weather_bridge.linux-arm64-gnu.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./pi_weather_bridge.linux-arm64-gnu.node')
} else {
nativeBinding = require('@tmustier/pi-weather-bridge-linux-arm64-gnu')
}
} catch (e) {
loadError = e
}
}
break
case 'arm':
if (isMusl()) {
localFileExisted = existsSync(
join(__dirname, 'pi_weather_bridge.linux-arm-musleabihf.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./pi_weather_bridge.linux-arm-musleabihf.node')
} else {
nativeBinding = require('@tmustier/pi-weather-bridge-linux-arm-musleabihf')
}
} catch (e) {
loadError = e
}
} else {
localFileExisted = existsSync(
join(__dirname, 'pi_weather_bridge.linux-arm-gnueabihf.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./pi_weather_bridge.linux-arm-gnueabihf.node')
} else {
nativeBinding = require('@tmustier/pi-weather-bridge-linux-arm-gnueabihf')
}
} catch (e) {
loadError = e
}
}
break
case 'riscv64':
if (isMusl()) {
localFileExisted = existsSync(
join(__dirname, 'pi_weather_bridge.linux-riscv64-musl.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./pi_weather_bridge.linux-riscv64-musl.node')
} else {
nativeBinding = require('@tmustier/pi-weather-bridge-linux-riscv64-musl')
}
} catch (e) {
loadError = e
}
} else {
localFileExisted = existsSync(
join(__dirname, 'pi_weather_bridge.linux-riscv64-gnu.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./pi_weather_bridge.linux-riscv64-gnu.node')
} else {
nativeBinding = require('@tmustier/pi-weather-bridge-linux-riscv64-gnu')
}
} catch (e) {
loadError = e
}
}
break
case 's390x':
localFileExisted = existsSync(
join(__dirname, 'pi_weather_bridge.linux-s390x-gnu.node')
)
try {
if (localFileExisted) {
nativeBinding = require('./pi_weather_bridge.linux-s390x-gnu.node')
} else {
nativeBinding = require('@tmustier/pi-weather-bridge-linux-s390x-gnu')
}
} catch (e) {
loadError = e
}
break
default:
throw new Error(`Unsupported architecture on Linux: ${arch}`)
}
break
default:
throw new Error(`Unsupported OS: ${platform}, architecture: ${arch}`)
}
if (!nativeBinding) {
if (loadError) {
throw loadError
}
throw new Error(`Failed to load native binding`)
}
const { NativeWeatherProcess } = nativeBinding
module.exports.NativeWeatherProcess = NativeWeatherProcess
{
"name": "@tmustier/pi-weather-bridge",
"version": "0.1.0",
"private": true,
"description": "Prebuilt native bridge for @tmustier/pi-weather",
"license": "MIT",
"author": "Thomas Mustier",
"repository": {
"type": "git",
"url": "git+https://github.com/tmustier/pi-extensions.git",
"directory": "weather/native/weathr-bridge"
},
"bugs": "https://github.com/tmustier/pi-extensions/issues",
"homepage": "https://github.com/tmustier/pi-extensions/tree/main/weather",
"main": "index.js",
"types": "index.d.ts",
"napi": {
"name": "pi_weather_bridge",
"triples": {
"defaults": false,
"additional": [
"aarch64-apple-darwin",
"x86_64-apple-darwin",
"x86_64-unknown-linux-gnu",
"x86_64-pc-windows-msvc"
]
}
},
"scripts": {
"build": "napi build --platform --release --dts native.d.ts",
"build:debug": "napi build --platform --dts native.d.ts",
"build:platform": "napi build --platform --release --dts native.d.ts",
"build:platform:debug": "napi build --platform --dts native.d.ts",
"create-npm-dir": "napi create-npm-dir -t .",
"artifacts": "napi artifacts",
"napi": "napi"
},
"devDependencies": {
"@napi-rs/cli": "^2.18.2"
}
}
use napi::Result as NapiResult;
use napi_derive::napi;
use std::io::{ErrorKind, Read, Write};
#[cfg(unix)]
use std::os::unix::process::ExitStatusExt;
use std::process::{Child, ChildStdin, Command, ExitStatus, Stdio};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
#[napi(object)]
pub struct WeatherProcessSnapshot {
pub stdout: String,
pub stderr: String,
pub exited: bool,
pub exit_code: Option<i32>,
pub exit_signal: Option<String>,
}
struct SpawnedProcess {
child: Child,
stdin: ChildStdin,
}
#[napi]
pub struct NativeWeatherProcess {
script_path: String,
weathr_path: String,
args: Vec<String>,
config_home: String,
columns: u16,
rows: u16,
child: Option<Child>,
stdin: Option<ChildStdin>,
stdout_buffer: Arc<Mutex<Vec<u8>>>,
stderr_buffer: Arc<Mutex<Vec<u8>>>,
exited: bool,
exit_code: Option<i32>,
exit_signal: Option<String>,
}
#[napi]
impl NativeWeatherProcess {
#[napi(constructor)]
pub fn new(
script_path: String,
weathr_path: String,
args: Vec<String>,
config_home: String,
columns: u16,
rows: u16,
) -> Self {
let stdout_buffer = Arc::new(Mutex::new(Vec::new()));
let stderr_buffer = Arc::new(Mutex::new(Vec::new()));
let (child, stdin, exited, exit_signal) = match spawn_weathr_process(
&script_path,
&weathr_path,
&args,
&config_home,
columns,
rows,
stdout_buffer.clone(),
stderr_buffer.clone(),
) {
Ok(spawned) => (Some(spawned.child), Some(spawned.stdin), false, None),
Err(error) => {
let message = format!("Failed to start native weather bridge: {error}");
if let Ok(mut stderr) = stderr_buffer.lock() {
stderr.extend_from_slice(message.as_bytes());
}
(None, None, true, Some("start error".to_owned()))
}
};
Self {
script_path,
weathr_path,
args,
config_home,
columns,
rows,
child,
stdin,
stdout_buffer,
stderr_buffer,
exited,
exit_code: None,
exit_signal,
}
}
#[napi]
pub fn poll(&mut self) -> WeatherProcessSnapshot {
self.update_exit_state();
WeatherProcessSnapshot {
stdout: take_buffer_string(&self.stdout_buffer),
stderr: take_buffer_string(&self.stderr_buffer),
exited: self.exited,
exit_code: self.exit_code,
exit_signal: self.exit_signal.clone(),
}
}
#[napi]
pub fn write_input(&mut self, input: String) -> bool {
if self.exited {
return false;
}
let Some(stdin) = self.stdin.as_mut() else {
return false;
};
if stdin.write_all(input.as_bytes()).is_err() {
return false;
}
stdin.flush().is_ok()
}
#[napi]
pub fn stop(&mut self) {
if self.exited {
return;
}
let _ = self.write_input("q".to_owned());
thread::sleep(Duration::from_millis(100));
self.stdin = None;
let Some(mut child) = self.child.take() else {
self.exited = true;
self.exit_code = None;
self.exit_signal = Some("stopped".to_owned());
return;
};
let status = match child.try_wait() {
Ok(Some(status)) => Some(status),
Ok(None) => {
let _ = child.kill();
child.wait().ok()
}
Err(_) => None,
};
if let Some(status) = status {
self.record_exit_status(status);
} else {
self.exited = true;
self.exit_code = None;
self.exit_signal = Some("terminated".to_owned());
}
}
#[napi]
pub fn restart(&mut self) -> NapiResult<()> {
self.stop();
self.exited = false;
self.exit_code = None;
self.exit_signal = None;
let stdout_buffer = Arc::new(Mutex::new(Vec::new()));
let stderr_buffer = Arc::new(Mutex::new(Vec::new()));
let spawned = spawn_weathr_process(
&self.script_path,
&self.weathr_path,
&self.args,
&self.config_home,
self.columns,
self.rows,
stdout_buffer.clone(),
stderr_buffer.clone(),
)?;
self.child = Some(spawned.child);
self.stdin = Some(spawned.stdin);
self.stdout_buffer = stdout_buffer;
self.stderr_buffer = stderr_buffer;
Ok(())
}
#[napi]
pub fn resize(&mut self, columns: u16, rows: u16) -> NapiResult<()> {
self.columns = columns;
self.rows = rows;
self.restart()
}
#[napi]
pub fn is_running(&mut self) -> bool {
self.update_exit_state();
!self.exited
}
}
impl NativeWeatherProcess {
fn update_exit_state(&mut self) {
if self.exited {
return;
}
let Some(child) = self.child.as_mut() else {
return;
};
match child.try_wait() {
Ok(Some(status)) => {
self.stdin = None;
self.child = None;
self.record_exit_status(status);
}
Ok(None) => {}
Err(error) => {
self.stdin = None;
self.child = None;
self.exited = true;
self.exit_code = None;
self.exit_signal = Some(format!("wait error: {error}"));
}
}
}
fn record_exit_status(&mut self, status: ExitStatus) {
self.exited = true;
self.exit_code = status.code();
self.exit_signal = exit_signal_label(status);
}
}
impl Drop for NativeWeatherProcess {
fn drop(&mut self) {
self.stop();
}
}
fn spawn_weathr_process(
script_path: &str,
weathr_path: &str,
args: &[String],
config_home: &str,
columns: u16,
rows: u16,
stdout_buffer: Arc<Mutex<Vec<u8>>>,
stderr_buffer: Arc<Mutex<Vec<u8>>>,
) -> NapiResult<SpawnedProcess> {
let escaped_binary = shell_quote(weathr_path);
let escaped_args = args
.iter()
.map(|value| shell_quote(value))
.collect::<Vec<String>>()
.join(" ");
let weather_command = if escaped_args.is_empty() {
escaped_binary
} else {
format!("{escaped_binary} {escaped_args}")
};
let shell_command = format!("stty cols {columns} rows {rows}; exec {weather_command}");
let mut command = Command::new(script_path);
command
.arg("-q")
.arg("/dev/null")
.arg("sh")
.arg("-c")
.arg(shell_command)
.env("XDG_CONFIG_HOME", config_home)
.env_remove("NO_COLOR")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if std::env::var_os("COLORTERM").is_none() {
command.env("COLORTERM", "truecolor");
}
if std::env::var_os("TERM").is_none() {
command.env("TERM", "xterm-256color");
}
let mut child = command.spawn().map_err(|error| {
napi::Error::from_reason(format!("Failed to start weather process: {error}"))
})?;
let stdin = child
.stdin
.take()
.ok_or_else(|| napi::Error::from_reason("Failed to open weather stdin".to_owned()))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| napi::Error::from_reason("Failed to open weather stdout".to_owned()))?;
let stderr = child
.stderr
.take()
.ok_or_else(|| napi::Error::from_reason("Failed to open weather stderr".to_owned()))?;
spawn_reader_thread(stdout, stdout_buffer);
spawn_reader_thread(stderr, stderr_buffer);
Ok(SpawnedProcess { child, stdin })
}
fn spawn_reader_thread<R>(mut reader: R, buffer: Arc<Mutex<Vec<u8>>>)
where
R: Read + Send + 'static,
{
thread::spawn(move || {
let mut chunk = [0_u8; 8192];
loop {
match reader.read(&mut chunk) {
Ok(0) => break,
Ok(size) => {
let Ok(mut shared) = buffer.lock() else {
break;
};
shared.extend_from_slice(&chunk[..size]);
}
Err(error) if error.kind() == ErrorKind::Interrupted => continue,
Err(_) => break,
}
}
});
}
fn take_buffer_string(buffer: &Arc<Mutex<Vec<u8>>>) -> String {
let Ok(mut shared) = buffer.lock() else {
return String::new();
};
if shared.is_empty() {
return String::new();
}
let bytes = std::mem::take(&mut *shared);
String::from_utf8_lossy(&bytes).into_owned()
}
fn shell_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
fn exit_signal_label(status: ExitStatus) -> Option<String> {
#[cfg(unix)]
{
status.signal().map(|signal| signal.to_string())
}
#[cfg(not(unix))]
{
let _ = status;
None
}
}
{
"name": "@tmustier/pi-weather",
"version": "0.1.1",
"description": "Weather widget for Pi (/weather)",
"license": "MIT",
"author": "Thomas Mustier",
"keywords": [
"pi-package"
],
"repository": {
"type": "git",
"url": "git+https://github.com/tmustier/pi-extensions.git",
"directory": "weather"
},
"bugs": "https://github.com/tmustier/pi-extensions/issues",
"homepage": "https://github.com/tmustier/pi-extensions/tree/main/weather",
"peerDependencies": {
"@mariozechner/pi-coding-agent": "*",
"@mariozechner/pi-tui": "*"
},
"optionalDependencies": {
"@tmustier/pi-weather-bridge-darwin-arm64": "0.1.0",
"@tmustier/pi-weather-bridge-darwin-x64": "0.1.0",
"@tmustier/pi-weather-bridge-linux-x64-gnu": "0.1.0",
"@tmustier/pi-weather-bridge-win32-x64-msvc": "0.1.0"
},
"scripts": {
"build:native": "cd native/weathr-bridge && npm install --no-package-lock && npm run build",
"build:native:debug": "cd native/weathr-bridge && npm install --no-package-lock && npm run build:debug",
"native:prepare-packages": "cd native/weathr-bridge && npm install --no-package-lock && npm run create-npm-dir",
"native:sync-artifacts": "cd native/weathr-bridge && npm install --no-package-lock && npm run artifacts",
"native:publish-packages": "cd native/weathr-bridge && for pkg in npm/*; do if ls \"$pkg\"/*.node >/dev/null 2>&1; then npm publish \"./$pkg\" --access public --provenance; else echo \"Skipping $pkg (no binary)\"; fi; done"
},
"files": [
"index.ts",
"README.md",
"CHANGELOG.md",
"LICENSE",
"native/weathr-bridge/build.rs",
"native/weathr-bridge/Cargo.toml",
"native/weathr-bridge/index.d.ts",
"native/weathr-bridge/index.js",
"native/weathr-bridge/package.json",
"native/weathr-bridge/src/lib.rs"
],
"pi": {
"extensions": [
"index.ts"
],
"video": "https://raw.githubusercontent.com/tmustier/pi-extensions/main/weather/assets/weather-demo.mp4"
}
}
# Weather Widget Extension
Run the [weathr](https://github.com/veirt/weathr) terminal weather app inside Pi via `/weather`.
It opens in the main widget area above the input box (same interaction style as `/snake`), supports live weather + simulation flags, keeps controls inside Pi, and preserves ANSI colors.
The extension prefers a Rust N-API bridge (`native/weathr-bridge`) and falls back to a shell bridge if native isn't built.
## Demo
[![Weather widget demo](https://raw.githubusercontent.com/tmustier/pi-extensions/main/weather/assets/weather-demo.gif)](https://raw.githubusercontent.com/tmustier/pi-extensions/main/weather/assets/weather-demo.mp4)
[Open MP4 demo directly](https://raw.githubusercontent.com/tmustier/pi-extensions/main/weather/assets/weather-demo.mp4)
_Demo media is loaded from GitHub links and kept out of npm installs (package `files` whitelist + repo `.npmignore`)._
## Install
### Pi package manager
```bash
pi install npm:@tmustier/pi-weather
```
```bash
pi install git:github.com/tmustier/pi-extensions
```
Then filter to just this extension in `~/.pi/agent/settings.json`:
```json
{
"packages": [
{
"source": "git:github.com/tmustier/pi-extensions",
"extensions": ["weather/index.ts"]
}
]
}
```
### Local clone
```bash
ln -s ~/pi-extensions/weather ~/.pi/agent/extensions/weather
```
Or add to `~/.pi/agent/settings.json`:
```json
{
"extensions": ["~/pi-extensions/weather"]
}
```
## Commands
- `/weather` — open live weather widget
- `/weather rain` — shortcut for `--simulate rain`
- `/weather --simulate snow --night`
- `/weather-config` — edit widget config (`config.toml`)
While open:
- `Esc` or `Q` closes the widget
- `R` restarts the weather process
## Requirements
- `weathr` installed and available on PATH (or in `~/.cargo/bin/weathr`)
- `script` command available (macOS default, `util-linux` on Linux)
Install weathr:
```bash
cargo install weathr
```
Build the native Rust bridge locally (optional, for development):
```bash
cd ~/pi-extensions/weather
npm run build:native
```
Requires Rust + Node.
For npm users, the extension can load prebuilt optional packages (`@tmustier/pi-weather-bridge-*`) when published.
Troubleshooting:
- The extension auto-falls back to shell mode if native bridge has no output.
- If no matching prebuilt native package is installed for your platform, it falls back to shell mode.
- It explicitly unsets `NO_COLOR` for the weather child process and sets `COLORTERM=truecolor` when missing.
- Shell fallback binds `script` stdin to `/dev/null` (avoids Bun socket `tcgetattr` issues while preserving ANSI color output and ESC handling in Pi).
- Force shell mode manually:
```bash
PI_WEATHER_NATIVE=0 pi
```
## Config Location
The extension uses an isolated config home:
- `~/.pi/weather-widget/weathr/config.toml`
Use `/weather-config` to edit it.
> If you set custom `latitude`/`longitude`, also set `location.auto = false` or `weathr` will keep auto-detecting your location.
## Publishing native prebuilt packages
To ship `weathr-bridge` without requiring Rust at install time:
- Run GitHub Actions workflow `.github/workflows/weather-native-bridge.yml` (manual `workflow_dispatch`).
- The workflow builds prebuilt `.node` files per target, syncs them into `native/weathr-bridge/npm/*`, and publishes `@tmustier/pi-weather-bridge-*` platform packages.
- Then publish `@tmustier/pi-weather` (this extension) so consumers pick up the matching optional dependency versions.
- Keep versions in sync (`weather/package.json`, `native/weathr-bridge/package.json`, and `native/weathr-bridge/npm/*/package.json`).
Manual fallback (if not using the workflow):
```bash
cd ~/pi-extensions/weather
npm run native:prepare-packages
# build / download per-target pi_weather_bridge.<target>.node files into native/weathr-bridge/artifacts
npm run native:sync-artifacts
npm run native:publish-packages
```
## Changelog
See `CHANGELOG.md`.
+3
-3
{
"name": "@tmustier/pi-files-widget",
"version": "0.1.14",
"version": "0.1.15",
"description": "In-terminal file browser and viewer for Pi.",

@@ -18,4 +18,4 @@ "license": "MIT",

"peerDependencies": {
"@mariozechner/pi-coding-agent": "^0.50.0",
"@mariozechner/pi-tui": "^0.50.0"
"@mariozechner/pi-coding-agent": "*",
"@mariozechner/pi-tui": "*"
},

@@ -22,0 +22,0 @@ "scripts": {

{
"name": "pi-extensions",
"version": "0.1.21",
"version": "0.1.22",
"license": "MIT",

@@ -5,0 +5,0 @@ "private": false,

@@ -605,2 +605,7 @@ /**

description: "Start a long-running development loop. Use for complex multi-iteration tasks.",
promptSnippet: "Start a persistent multi-iteration development loop with pacing and reflection controls.",
promptGuidelines: [
"Use this tool when the user explicitly wants an iterative loop, autonomous repeated passes, or paced multi-step execution.",
"After starting a loop, continue each finished iteration with ralph_done unless the completion marker has already been emitted.",
],
parameters: Type.Object({

@@ -657,2 +662,7 @@ name: Type.String({ description: "Loop name (e.g., 'refactor-auth')" }),

description: "Signal that you've completed this iteration of the Ralph loop. Call this after making progress to get the next iteration prompt. Do NOT call this if you've output the completion marker.",
promptSnippet: "Advance an active Ralph loop after completing the current iteration.",
promptGuidelines: [
"Call this after making real iteration progress so Ralph can queue the next prompt.",
"Do not call this if there is no active loop, if pending messages are already queued, or if the completion marker has already been emitted.",
],
parameters: Type.Object({}),

@@ -659,0 +669,0 @@ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {

{
"name": "@tmustier/pi-ralph-wiggum",
"version": "0.1.5",
"version": "0.1.6",
"description": "Long-running agent loops for iterative development in Pi.",

@@ -5,0 +5,0 @@ "license": "MIT",