diff --git a/.claude/commands/release.md b/.claude/commands/release.md
index 49e447349..e9109ef30 100644
--- a/.claude/commands/release.md
+++ b/.claude/commands/release.md
@@ -4,7 +4,7 @@ Release all packages with the specified version.
## Arguments
-- `$ARGUMENTS`: The version number (e.g., `1.342.0`)
+- `$ARGUMENTS`: The version number (semver)
## Pre-flight Checks
diff --git a/README.md b/README.md
index 4b0e3e045..914d1757f 100644
--- a/README.md
+++ b/README.md
@@ -19,6 +19,35 @@
Fork of badlogic/pi-mono by @mariozechner
+---
+
+## Installation
+
+### Via Bun (recommended)
+
+Requires [Bun](https://bun.sh) runtime:
+
+```bash
+bun install -g @oh-my-pi/pi-coding-agent
+```
+
+### Via installer script
+
+**Linux / macOS:**
+```bash
+curl -fsSL https://raw.githubusercontent.com/can1357/oh-my-pi/main/scripts/install.sh | sh
+```
+
+**Windows (PowerShell):**
+```powershell
+irm https://raw.githubusercontent.com/can1357/oh-my-pi/main/scripts/install.ps1 | iex
+```
+
+### Manual download
+
+Download binaries directly from [GitHub Releases](https://github.com/can1357/oh-my-pi/releases/latest).
+
+---
## + LSP Integration (Language Server Protocol)
diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md
index 616ceb312..c37394335 100644
--- a/packages/coding-agent/CHANGELOG.md
+++ b/packages/coding-agent/CHANGELOG.md
@@ -1,8 +1,15 @@
# Changelog
## [Unreleased]
+
+### Added
+
+- Added `pi update` command to check for and install updates from GitHub releases or via bun
+
### Changed
+- Changed HTML export to use compile-time bundled templates via Bun macros for improved performance
+- Changed `exportToHtml` and `exportFromFile` functions to be async
- Simplified build process by embedding assets (themes, templates, agents, commands) directly into the binary at compile time
- Removed separate asset copying steps from build scripts
diff --git a/packages/coding-agent/src/bun-imports.d.ts b/packages/coding-agent/src/bun-imports.d.ts
index 87e251caa..169a6791d 100644
--- a/packages/coding-agent/src/bun-imports.d.ts
+++ b/packages/coding-agent/src/bun-imports.d.ts
@@ -1,6 +1,6 @@
/**
* Type declarations for Bun's import attributes.
- * These allow importing non-JS files as text or JSON at build time.
+ * These allow importing non-JS files as text at build time.
*/
// Markdown files imported as text
@@ -9,18 +9,6 @@ declare module "*.md" {
export default content;
}
-// CSS files imported as text
-declare module "*.css" {
- const content: string;
- export default content;
-}
-
-// HTML files imported as text
-declare module "*.html" {
- const content: string;
- export default content;
-}
-
// Text files imported as text
declare module "*.txt" {
const content: string;
diff --git a/packages/coding-agent/src/cli/update-cli.ts b/packages/coding-agent/src/cli/update-cli.ts
new file mode 100644
index 000000000..66dc2123a
--- /dev/null
+++ b/packages/coding-agent/src/cli/update-cli.ts
@@ -0,0 +1,273 @@
+/**
+ * Update CLI command handler.
+ *
+ * Handles `pi update` to check for and install updates.
+ * Uses bun if available, otherwise downloads binary from GitHub releases.
+ */
+
+import { execSync, spawnSync } from "node:child_process";
+import { createWriteStream, existsSync, renameSync, unlinkSync } from "node:fs";
+import { dirname } from "node:path";
+import { Readable } from "node:stream";
+import { pipeline } from "node:stream/promises";
+import chalk from "chalk";
+import { VERSION } from "../config";
+
+/**
+ * Detect if we're running as a Bun compiled binary.
+ */
+const isBunBinary =
+ import.meta.url.includes("$bunfs") || import.meta.url.includes("~BUN") || import.meta.url.includes("%7EBUN");
+
+const REPO = "can1357/oh-my-pi";
+const PACKAGE = "@oh-my-pi/pi-coding-agent";
+
+interface ReleaseInfo {
+ tag: string;
+ version: string;
+ assets: Array<{ name: string; url: string }>;
+}
+
+/**
+ * Parse update subcommand arguments.
+ * Returns undefined if not an update command.
+ */
+export function parseUpdateArgs(args: string[]): { force: boolean; check: boolean } | undefined {
+ if (args.length === 0 || args[0] !== "update") {
+ return undefined;
+ }
+
+ return {
+ force: args.includes("--force") || args.includes("-f"),
+ check: args.includes("--check") || args.includes("-c"),
+ };
+}
+
+/**
+ * Check if bun is available in PATH.
+ */
+function hasBun(): boolean {
+ try {
+ const result = spawnSync("bun", ["--version"], { encoding: "utf-8", stdio: "pipe" });
+ return result.status === 0;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Get the latest release info from GitHub.
+ */
+async function getLatestRelease(): Promise {
+ const response = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`);
+ if (!response.ok) {
+ throw new Error(`Failed to fetch release info: ${response.statusText}`);
+ }
+
+ const data = (await response.json()) as {
+ tag_name: string;
+ assets: Array<{ name: string; browser_download_url: string }>;
+ };
+
+ return {
+ tag: data.tag_name,
+ version: data.tag_name.replace(/^v/, ""),
+ assets: data.assets.map((a) => ({ name: a.name, url: a.browser_download_url })),
+ };
+}
+
+/**
+ * Compare semver versions. Returns:
+ * - negative if a < b
+ * - 0 if a == b
+ * - positive if a > b
+ */
+function compareVersions(a: string, b: string): number {
+ const pa = a.split(".").map(Number);
+ const pb = b.split(".").map(Number);
+
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
+ const na = pa[i] || 0;
+ const nb = pb[i] || 0;
+ if (na !== nb) return na - nb;
+ }
+ return 0;
+}
+
+/**
+ * Get the appropriate binary name for this platform.
+ */
+function getBinaryName(): string {
+ const platform = process.platform;
+ const arch = process.arch;
+
+ let os: string;
+ switch (platform) {
+ case "linux":
+ os = "linux";
+ break;
+ case "darwin":
+ os = "darwin";
+ break;
+ case "win32":
+ os = "windows";
+ break;
+ default:
+ throw new Error(`Unsupported platform: ${platform}`);
+ }
+
+ let archName: string;
+ switch (arch) {
+ case "x64":
+ archName = "x64";
+ break;
+ case "arm64":
+ archName = "arm64";
+ break;
+ default:
+ throw new Error(`Unsupported architecture: ${arch}`);
+ }
+
+ if (os === "windows") {
+ return `pi-${os}-${archName}.exe`;
+ }
+ return `pi-${os}-${archName}`;
+}
+
+/**
+ * Update via bun package manager.
+ */
+async function updateViaBun(): Promise {
+ console.log(chalk.dim("Updating via bun..."));
+
+ try {
+ execSync(`bun update -g ${PACKAGE}`, { stdio: "inherit" });
+ console.log(chalk.green("\nā Update complete"));
+ } catch {
+ throw new Error("bun update failed");
+ }
+}
+
+/**
+ * Update by downloading binary from GitHub releases.
+ */
+async function updateViaBinary(release: ReleaseInfo): Promise {
+ const binaryName = getBinaryName();
+ const asset = release.assets.find((a) => a.name === binaryName);
+
+ if (!asset) {
+ throw new Error(`No binary found for ${binaryName}`);
+ }
+
+ const execPath = process.execPath;
+ const _execDir = dirname(execPath);
+ const tempPath = `${execPath}.new`;
+ const backupPath = `${execPath}.bak`;
+
+ console.log(chalk.dim(`Downloading ${binaryName}...`));
+
+ // Download to temp file
+ const response = await fetch(asset.url, { redirect: "follow" });
+ if (!response.ok || !response.body) {
+ throw new Error(`Download failed: ${response.statusText}`);
+ }
+
+ const fileStream = createWriteStream(tempPath, { mode: 0o755 });
+ const nodeStream = Readable.fromWeb(response.body as import("stream/web").ReadableStream);
+ await pipeline(nodeStream, fileStream);
+
+ // Replace current binary
+ console.log(chalk.dim("Installing update..."));
+
+ try {
+ // Backup current binary
+ if (existsSync(backupPath)) {
+ unlinkSync(backupPath);
+ }
+ renameSync(execPath, backupPath);
+
+ // Move new binary into place
+ renameSync(tempPath, execPath);
+
+ // Clean up backup
+ unlinkSync(backupPath);
+
+ console.log(chalk.green(`\nā Updated to ${release.version}`));
+ console.log(chalk.dim("Restart pi to use the new version"));
+ } catch (err) {
+ // Restore from backup if possible
+ if (existsSync(backupPath) && !existsSync(execPath)) {
+ renameSync(backupPath, execPath);
+ }
+ if (existsSync(tempPath)) {
+ unlinkSync(tempPath);
+ }
+ throw err;
+ }
+}
+
+/**
+ * Run the update command.
+ */
+export async function runUpdateCommand(opts: { force: boolean; check: boolean }): Promise {
+ console.log(chalk.dim(`Current version: ${VERSION}`));
+
+ // Check for updates
+ let release: ReleaseInfo;
+ try {
+ release = await getLatestRelease();
+ } catch (err) {
+ console.error(chalk.red(`Failed to check for updates: ${err}`));
+ process.exit(1);
+ }
+
+ const comparison = compareVersions(release.version, VERSION);
+
+ if (comparison <= 0 && !opts.force) {
+ console.log(chalk.green("ā Already up to date"));
+ return;
+ }
+
+ if (comparison > 0) {
+ console.log(chalk.cyan(`New version available: ${release.version}`));
+ } else {
+ console.log(chalk.yellow(`Forcing reinstall of ${release.version}`));
+ }
+
+ if (opts.check) {
+ // Just check, don't install
+ return;
+ }
+
+ // Choose update method
+ try {
+ if (!isBunBinary && hasBun()) {
+ await updateViaBun();
+ } else {
+ await updateViaBinary(release);
+ }
+ } catch (err) {
+ console.error(chalk.red(`Update failed: ${err}`));
+ process.exit(1);
+ }
+}
+
+/**
+ * Print update command help.
+ */
+export function printUpdateHelp(): void {
+ console.log(`${chalk.bold("pi update")} - Check for and install updates
+
+${chalk.bold("Usage:")}
+ pi update [options]
+
+${chalk.bold("Options:")}
+ -c, --check Check for updates without installing
+ -f, --force Force reinstall even if up to date
+
+${chalk.bold("Examples:")}
+ pi update Update to latest version
+ pi update --check Check if updates are available
+ pi update --force Force reinstall
+`);
+}
diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts
index d5943e0cb..04335ba78 100644
--- a/packages/coding-agent/src/core/agent-session.ts
+++ b/packages/coding-agent/src/core/agent-session.ts
@@ -1900,7 +1900,7 @@ export class AgentSession {
* @param outputPath Optional output path (defaults to session directory)
* @returns Path to exported file
*/
- exportToHtml(outputPath?: string): string {
+ async exportToHtml(outputPath?: string): Promise {
const themeName = this.settingsManager.getTheme();
return exportSessionToHtml(this.sessionManager, this.state, { outputPath, themeName });
}
diff --git a/packages/coding-agent/src/core/export-html/index.ts b/packages/coding-agent/src/core/export-html/index.ts
index 07146a7d3..372014860 100644
--- a/packages/coding-agent/src/core/export-html/index.ts
+++ b/packages/coding-agent/src/core/export-html/index.ts
@@ -4,41 +4,16 @@ import type { AgentState } from "@oh-my-pi/pi-agent-core";
import { APP_NAME } from "../../config";
import { getResolvedThemeColors, getThemeExportColors } from "../../modes/interactive/theme/theme";
import { SessionManager } from "../session-manager";
-// Embed template files at build time
-import templateCss from "./template-css.txt" with { type: "text" };
-import templateHtml from "./template-html.txt" with { type: "text" };
-import templateJs from "./template-js.txt" with { type: "text" };
-/** Minify CSS by removing comments, unnecessary whitespace, and newlines. */
-function minifyCss(css: string): string {
- return css
- .replace(/\/\*[\s\S]*?\*\//g, "") // Remove comments
- .replace(/\s+/g, " ") // Collapse whitespace
- .replace(/\s*([{}:;,>+~])\s*/g, "$1") // Remove space around punctuation
- .replace(/;}/g, "}") // Remove trailing semicolons
- .trim();
-}
-
-/** Minify JS using Bun's transpiler. */
-function minifyJs(js: string): string {
- const transpiler = new Bun.Transpiler({ loader: "js", minifyWhitespace: true });
- return transpiler.transformSync(js);
-}
-
-/** Minify HTML by collapsing whitespace outside of tags. */
-function minifyHtml(html: string): string {
- return html
- .replace(/>\s+<") // Remove whitespace between tags
- .replace(/\s{2,}/g, " ") // Collapse multiple spaces
- .trim();
-}
+// Bun macro: bundles HTML+CSS+JS at compile time, evaluated at bundle time
+import { getTemplate } from "./template.macro" with { type: "macro" };
export interface ExportOptions {
outputPath?: string;
themeName?: string;
}
-/** Parse a color string to RGB values. Supports hex (#RRGGBB) and rgb(r,g,b) formats. */
+/** Parse a color string to RGB values. */
function parseColor(color: string): { r: number; g: number; b: number } | undefined {
const hexMatch = color.match(/^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/);
if (hexMatch) {
@@ -68,7 +43,7 @@ function getLuminance(r: number, g: number, b: number): number {
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
}
-/** Adjust color brightness. Factor > 1 lightens, < 1 darkens. */
+/** Adjust color brightness. */
function adjustBrightness(color: string, factor: number): string {
const parsed = parseColor(color);
if (!parsed) return color;
@@ -76,21 +51,15 @@ function adjustBrightness(color: string, factor: number): string {
return `rgb(${adjust(parsed.r)}, ${adjust(parsed.g)}, ${adjust(parsed.b)})`;
}
-/** Derive export background colors from a base color (e.g., userMessageBg). */
+/** Derive export background colors from a base color. */
function deriveExportColors(baseColor: string): { pageBg: string; cardBg: string; infoBg: string } {
const parsed = parseColor(baseColor);
if (!parsed) {
- return {
- pageBg: "rgb(24, 24, 30)",
- cardBg: "rgb(30, 30, 36)",
- infoBg: "rgb(60, 55, 40)",
- };
+ return { pageBg: "rgb(24, 24, 30)", cardBg: "rgb(30, 30, 36)", infoBg: "rgb(60, 55, 40)" };
}
const luminance = getLuminance(parsed.r, parsed.g, parsed.b);
- const isLight = luminance > 0.5;
-
- if (isLight) {
+ if (luminance > 0.5) {
return {
pageBg: adjustBrightness(baseColor, 0.96),
cardBg: baseColor,
@@ -104,9 +73,7 @@ function deriveExportColors(baseColor: string): { pageBg: string; cardBg: string
};
}
-/**
- * Generate CSS custom property declarations from theme colors.
- */
+/** Generate CSS custom properties for theme. */
function generateThemeVars(themeName?: string): string {
const colors = getResolvedThemeColors(themeName);
const lines: string[] = [];
@@ -114,16 +81,15 @@ function generateThemeVars(themeName?: string): string {
lines.push(`--${key}: ${value};`);
}
- // Use explicit theme export colors if available, otherwise derive from userMessageBg
const themeExport = getThemeExportColors(themeName);
const userMessageBg = colors.userMessageBg || "#343541";
- const derivedColors = deriveExportColors(userMessageBg);
+ const derived = deriveExportColors(userMessageBg);
- lines.push(`--exportPageBg: ${themeExport.pageBg ?? derivedColors.pageBg};`);
- lines.push(`--exportCardBg: ${themeExport.cardBg ?? derivedColors.cardBg};`);
- lines.push(`--exportInfoBg: ${themeExport.infoBg ?? derivedColors.infoBg};`);
+ lines.push(`--body-bg: ${themeExport.pageBg ?? derived.pageBg};`);
+ lines.push(`--container-bg: ${themeExport.cardBg ?? derived.cardBg};`);
+ lines.push(`--info-bg: ${themeExport.infoBg ?? derived.infoBg};`);
- return lines.join("\n ");
+ return lines.join(" ");
}
interface SessionData {
@@ -134,61 +100,28 @@ interface SessionData {
tools?: { name: string; description: string }[];
}
-// Pre-minified embedded assets (cached on first use)
-let cachedTemplate: string | null = null;
-let cachedJs: string | null = null;
-
-/**
- * Core HTML generation logic shared by both export functions.
- */
-function generateHtml(sessionData: SessionData, themeName?: string): string {
- // Minify embedded assets on first use
- if (!cachedTemplate) {
- cachedTemplate = minifyHtml(templateHtml);
- }
- if (!cachedJs) {
- cachedJs = minifyJs(templateJs);
- }
-
+/** Generate HTML from bundled template with runtime substitutions. */
+async function generateHtml(sessionData: SessionData, themeName?: string): Promise {
const themeVars = generateThemeVars(themeName);
- const colors = getResolvedThemeColors(themeName);
- const exportColors = deriveExportColors(colors.userMessageBg || "#343541");
- const bodyBg = exportColors.pageBg;
- const containerBg = exportColors.cardBg;
- const infoBg = exportColors.infoBg;
-
- // Base64 encode session data to avoid escaping issues
const sessionDataBase64 = Buffer.from(JSON.stringify(sessionData)).toString("base64");
+ const template = await getTemplate();
- // Build and minify the CSS with theme variables injected
- const css = minifyCss(
- templateCss
- .replace("{{THEME_VARS}}", themeVars)
- .replace("{{BODY_BG}}", bodyBg)
- .replace("{{CONTAINER_BG}}", containerBg)
- .replace("{{INFO_BG}}", infoBg),
- );
-
- return cachedTemplate
- .replace("{{CSS}}", css)
- .replace("{{JS}}", cachedJs)
+ return template
+ .replace("", ``)
.replace("{{SESSION_DATA}}", sessionDataBase64);
}
-/**
- * Export session to HTML using SessionManager and AgentState.
- * Used by TUI's /export command.
- */
-export function exportSessionToHtml(sm: SessionManager, state?: AgentState, options?: ExportOptions | string): string {
+/** Export session to HTML using SessionManager and AgentState. */
+export async function exportSessionToHtml(
+ sm: SessionManager,
+ state?: AgentState,
+ options?: ExportOptions | string,
+): Promise {
const opts: ExportOptions = typeof options === "string" ? { outputPath: options } : options || {};
const sessionFile = sm.getSessionFile();
- if (!sessionFile) {
- throw new Error("Cannot export in-memory session to HTML");
- }
- if (!existsSync(sessionFile)) {
- throw new Error("Nothing to export yet - start a conversation first");
- }
+ if (!sessionFile) throw new Error("Cannot export in-memory session to HTML");
+ if (!existsSync(sessionFile)) throw new Error("Nothing to export yet - start a conversation first");
const sessionData: SessionData = {
header: sm.getHeader(),
@@ -198,46 +131,28 @@ export function exportSessionToHtml(sm: SessionManager, state?: AgentState, opti
tools: state?.tools?.map((t) => ({ name: t.name, description: t.description })),
};
- const html = generateHtml(sessionData, opts.themeName);
-
- let outputPath = opts.outputPath;
- if (!outputPath) {
- const sessionBasename = basename(sessionFile, ".jsonl");
- outputPath = `${APP_NAME}-session-${sessionBasename}.html`;
- }
+ const html = await generateHtml(sessionData, opts.themeName);
+ const outputPath = opts.outputPath || `${APP_NAME}-session-${basename(sessionFile, ".jsonl")}.html`;
writeFileSync(outputPath, html, "utf8");
return outputPath;
}
-/**
- * Export session file to HTML (standalone, without AgentState).
- * Used by CLI for exporting arbitrary session files.
- */
-export function exportFromFile(inputPath: string, options?: ExportOptions | string): string {
+/** Export session file to HTML (standalone). */
+export async function exportFromFile(inputPath: string, options?: ExportOptions | string): Promise {
const opts: ExportOptions = typeof options === "string" ? { outputPath: options } : options || {};
- if (!existsSync(inputPath)) {
- throw new Error(`File not found: ${inputPath}`);
- }
+ if (!existsSync(inputPath)) throw new Error(`File not found: ${inputPath}`);
const sm = SessionManager.open(inputPath);
-
const sessionData: SessionData = {
header: sm.getHeader(),
entries: sm.getEntries(),
leafId: sm.getLeafId(),
- systemPrompt: undefined,
- tools: undefined,
};
- const html = generateHtml(sessionData, opts.themeName);
-
- let outputPath = opts.outputPath;
- if (!outputPath) {
- const inputBasename = basename(inputPath, ".jsonl");
- outputPath = `${APP_NAME}-session-${inputBasename}.html`;
- }
+ const html = await generateHtml(sessionData, opts.themeName);
+ const outputPath = opts.outputPath || `${APP_NAME}-session-${basename(inputPath, ".jsonl")}.html`;
writeFileSync(outputPath, html, "utf8");
return outputPath;
diff --git a/packages/coding-agent/src/core/export-html/template-css.txt b/packages/coding-agent/src/core/export-html/template.css
similarity index 99%
rename from packages/coding-agent/src/core/export-html/template-css.txt
rename to packages/coding-agent/src/core/export-html/template.css
index 0763a3fdb..31243ec85 100644
--- a/packages/coding-agent/src/core/export-html/template-css.txt
+++ b/packages/coding-agent/src/core/export-html/template.css
@@ -1,10 +1,3 @@
- :root {
- {{THEME_VARS}}
- --body-bg: {{BODY_BG}};
- --container-bg: {{CONTAINER_BG}};
- --info-bg: {{INFO_BG}};
- }
-
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
diff --git a/packages/coding-agent/src/core/export-html/template-html.txt b/packages/coding-agent/src/core/export-html/template.html
similarity index 97%
rename from packages/coding-agent/src/core/export-html/template-html.txt
rename to packages/coding-agent/src/core/export-html/template.html
index 76673d8ac..3afb4beb3 100644
--- a/packages/coding-agent/src/core/export-html/template-html.txt
+++ b/packages/coding-agent/src/core/export-html/template.html
@@ -4,9 +4,8 @@
Session Export
-
+
+
@@ -41,6 +40,6 @@
-
+