#!/usr/bin/env bun /** * port-to-omp.mjs — regenerate the omp port of pygenium from this repo. * * The omp port is "base + patch layer"; this script IS the patch layer. This * repo is the single source of truth; ~/.omp/agent/extensions/pygenium (or * --out) is a generated artifact. Every op asserts its target and fails * loudly on base drift — never silently producing a stale port. * * Usage: * bun port-to-omp.mjs # write ~/.omp/agent/extensions/pygenium * bun port-to-omp.mjs --out # write elsewhere (CI: the omp repo clone) * * CI: .gitea/workflows/port-to-omp.yml clones the omp-pygenium repo and * runs this script into it, then commits + pushes when the port changed. */ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs"; import { join } from "node:path"; import { execSync } from "node:child_process"; import { homedir } from "node:os"; const HOME = homedir(); const PI = join(HOME, ".pi", "agent", "extensions"); const OMP = join(HOME, ".omp", "agent", "extensions"); const SKIP = new Set([ "port-to-omp.mjs", ".gitea", ".github", "node_modules", ".git", ".DS_Store", ".pi-lens", "bun.lock", "package-lock.json", "dist", ]); const OMP_SDK = "17.2.12"; // ─── helpers ──────────────────────────────────────────────────────────────── function assertEdit(src, from, to, file, label = "") { const idx = src.indexOf(from); if (idx === -1) { throw new Error( `[${file}] target not found${label ? ` (${label})` : ""}:\n${from.slice(0, 300)}`, ); } return src.slice(0, idx) + to + src.slice(idx + from.length); } function replaceAll(src, from, to, file, label) { const parts = src.split(from); if (parts.length === 1) { throw new Error(`[${file}] target not found${label ? ` (${label})` : ""}:\n${from.slice(0, 300)}`); } return parts.join(to); } function reEdit(src, re, to, file, label) { const out = src.replace(re, to); if (out === src) { throw new Error(`[${file}] regex matched nothing (${label}): ${re}`); } return out; } function reAll(src, re, to, file, label) { let count = 0; const out = src.replace(re, (...args) => { count++; return typeof to === "function" ? to(...args) : to; }); if (count === 0) { throw new Error(`[${file}] regex matched nothing (${label}): ${re}`); } return out; } /** Run a list of ops over a file's text. op = {from,to} | {re,to,label}. */ function applyOps(src, ops, file) { for (const op of ops) { if ("from" in op) { src = op.all ? replaceAll(src, op.from, op.to, file, op.label) : assertEdit(src, op.from, op.to, file, op.label); } else { src = op.all ? reAll(src, op.re, op.to, file, op.label) : reEdit(src, op.re, op.to, file, op.label); } } return src; } function mirrorTree(srcDir, dstDir) { mkdirSync(dstDir, { recursive: true }); cpSync(srcDir, dstDir, { recursive: true, force: true, filter: (p) => !SKIP.has(p.split("/").pop()), }); // drop stale files in dst that no longer exist in src; keep .git* intact for (const rel of walk(dstDir)) { if (rel.startsWith(".git")) continue; if (!existsSync(join(srcDir, rel))) rmSync(join(dstDir, rel), { force: true, recursive: true }); } } function walk(dir) { const out = []; for (const entry of readdirSync(dir, { withFileTypes: true })) { if (SKIP.has(entry.name)) continue; const p = join(dir, entry.name); if (entry.isDirectory()) out.push(...walk(p).map((r) => join(entry.name, r))); else out.push(entry.name); } return out; } const SPECIFIERS = [ ["@earendil-works/pi-coding-agent", "@oh-my-pi/pi-coding-agent"], ["@earendil-works/pi-tui", "@oh-my-pi/pi-tui"], ["@earendil-works/pi-ai", "@oh-my-pi/pi-ai"], ["@earendil-works/pi-agent-core", "@oh-my-pi/pi-agent-core"], ]; function rewriteSpecifiers(src) { for (const [from, to] of SPECIFIERS) src = src.split(from).join(to); return src; } // ─── package.json transforms ──────────────────────────────────────────────── function pkgName(piName) { if (piName.startsWith("@mikefreno/")) return piName.replace(/^@mikefreno\//, "@mikefreno/omp-"); return `@mikefreno/omp-${piName}`; } function reorder(obj, keys) { const out = {}; for (const k of keys) if (k in obj) out[k] = obj[k]; for (const k of Object.keys(obj)) if (!(k in out)) out[k] = obj[k]; return out; } const PKG_RULES = { order: ["name", "version", "description", "keywords", "license", "type", "engines", "omp", "devDependencies", "scripts"], transform(p) { p.name = pkgName(p.name); p.description = p.description.replace("for pi —", "for omp (port of the pi extension) —"); p.keywords = ["omp", "omp-extension", ...p.keywords.slice(2)]; p.engines.bun = ">=1.3.14"; p.omp = p.pi; delete p.pi; delete p.peerDependencies; delete p.peerDependenciesMeta; p.devDependencies = { "@oh-my-pi/pi-coding-agent": OMP_SDK, "@oh-my-pi/pi-tui": OMP_SDK, ...p.devDependencies, }; }, }; function transformPkg() { const raw = JSON.parse(readFileSync(join(import.meta.dir, "package.json"), "utf8")); if (!raw.pi) throw new Error('expected "pi" manifest key in package.json'); const rule = PKG_RULES; rule.transform(raw); const ordered = reorder(raw, rule.order); return JSON.stringify(ordered, null, 2) + "\n"; } const FILE_RULES = { "src/index.ts": [ { from: " * Read the pygienium chat style from pi's settings.json.", to: " * Read the pygienium chat style from omp's settings.json." }, { from: " * Looks for `pygienium.chatStyle` under `~/.pi/agent/settings.json`.", to: " * Looks for `pygienium.chatStyle` under `~/.omp/agent/settings.json`." }, { from: '\t\t\tjoin(homedir(), ".pi", "agent", "settings.json"),', to: '\t\t\tjoin(homedir(), ".omp", "agent", "settings.json"),' }, { from: "\t\t\t\tconst pygieniumCtx: PygieniumCtx = {\n\t\t\t\t\tcwd: ctx.cwd,\n\t\t\t\t\tmode: ctx.mode,\n\t\t\t\t\thasUI: ctx.hasUI,", to: "\t\t\t\tconst pygieniumCtx: PygieniumCtx = {\n\t\t\t\t\tcwd: ctx.cwd,\n\t\t\t\t\thasUI: ctx.hasUI,", label: "ctx.mode removal", }, ], "src/commands.ts": [ { from: " * names. Each handler accepts the narrow context slice it needs (`cwd`, `mode`,", to: " * names. Each handler accepts the narrow context slice it needs (`cwd`," }, { from: '\t"cwd" | "mode" | "hasUI" | "ui"', to: '\t"cwd" | "hasUI" | "ui"', label: "PygieniumCtx Pick" }, { from: "\t// In TUI mode, also surface the first line as a notification.\n\tif (ctx.mode === \"tui\" && ctx.ui?.notify) {", to: "\t// With a dialog-capable UI, also surface the first line as a notification.\n\tif (ctx.hasUI && ctx.ui?.notify) {", label: "print notify", }, ], "src/agent-runner.ts": [ { from: "\t// Lazily import the SDK so the rest of the module graph (and tests using the\n\t// fake runner) never resolve the heavy pi-coding-agent package.\n\tconst {\n\t\tcreateAgentSession,\n\t\tDefaultResourceLoader,\n\t\tgetAgentDir,\n\t\tSessionManager,\n\t} = await import(\"@oh-my-pi/pi-coding-agent\");\n\tconst loader = new DefaultResourceLoader({\n\t\tcwd: opts.cwd,\n\t\tagentDir: getAgentDir(),\n\t\tsystemPromptOverride: () => agent.systemPrompt,\n\t\t// Keep the sub-agent isolated: no nested extensions/skills/themes/etc.\n\t\tnoExtensions: true,\n\t\tnoSkills: true,\n\t\tnoThemes: true,\n\t\tnoPromptTemplates: true,\n\t});\n\tawait loader.reload();", to: "\t// Lazily import the SDK so the rest of the module graph (and tests using the\n\t// fake runner) never resolve the heavy pi-coding-agent package. The specifier\n\t// is static by intent (dynamic-import exception: module is intentionally\n\t// excluded from the import-time graph to keep fake-runner tests SDK-free).\n\tconst { createAgentSession, AgentRegistry, SessionManager } = await import(\n\t\t\"@oh-my-pi/pi-coding-agent\"\n\t);", label: "SDK import + loader removal", }, { from: '\t\tagent.allowedTools ?? ["read", "bash", "grep", "find"];', to: '\t\tagent.allowedTools ?? ["read", "bash", "grep", "glob"];', label: "default tools" }, { from: "\tconst { session } = await createAgentSession({\n\t\tcwd: opts.cwd,\n\t\ttools,\n\t\tsessionManager: SessionManager.inMemory(opts.cwd),\n\t\tresourceLoader: loader,\n\t});", to: "\tconst { session } = await createAgentSession({\n\t\tcwd: opts.cwd,\n\t\ttoolNames: tools,\n\t\t// `tools` is an allowlist, not a request list.\n\t\trestrictToolNames: true,\n\t\tsessionManager: SessionManager.inMemory(opts.cwd),\n\t\t// Replace the fully rendered default prompt with the agent body.\n\t\tsystemPrompt: agent.systemPrompt,\n\t\t// Keep the sub-agent isolated: no nested extensions/skills/prompts/etc.\n\t\tdisableExtensionDiscovery: true,\n\t\tskills: [],\n\t\tpromptTemplates: [],\n\t\trules: [],\n\t\tcontextFiles: [],\n\t\tenableMCP: false,\n\t\tenableLsp: false,\n\t\t// Private registry: the host session owns the process-global \"Main\"\n\t\t// identity, so a per-run registry keeps these in-process workers\n\t\t// disjoint from the main agent.\n\t\tagentRegistry: new AgentRegistry(),\n\t});", label: "createAgentSession", }, ], "src/agents.ts": [{ from: " - find", to: " - glob", label: "frontmatter example" }], "README.md": [ { from: "[pi](https://github.com/earendil-works/pi-coding-agent)", to: "[omp](https://github.com/oh-my-pi)" }, { from: "local pi extension under `~/.pi/agent/extensions/pygienium/`.", to: "local omp extension under `~/.omp/agent/extensions/pygienium/`." }, { from: "Pi loads it via `pi.extensions`", to: "Omp loads it via `omp.extensions`" }, { from: "Pi auto-discovers the extension from this location via the `pi.extensions` entry", to: "Omp auto-discovers the extension from this location via the `omp.extensions` entry" }, { from: "pi's `settings.json` (`~/.pi/agent/settings.json`)", to: "omp's `settings.json` (`~/.omp/agent/settings.json`)" }, { from: "Reload pi (or `/reload`)", to: "Reload omp (or `/reload`)" }, ], }; const AGENT_MD_GLOB = { re: /`find`(?! \.)/g, to: "`glob`", label: "find token", all: true }; const AGENT_MD_FRONTMATTER = { from: " - find", to: " - glob", label: "frontmatter tools", all: true }; const PYGENIUM_TSCONFIG = `{ "compilerOptions": { "target": "ES2022", "module": "ES2022", "moduleResolution": "bundler", "lib": ["ES2022"], "types": ["node"], "noEmit": true, "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true }, "include": ["src/**/*.ts"], "exclude": ["node_modules", "dist"] } `; // ─── main ─────────────────────────────────────────────────────────────────── const OUT_DEFAULT = join(homedir(), ".omp", "agent", "extensions", "pygenium"); function portExtension() { const srcDir = import.meta.dir; const dstDir = process.argv.includes("--out") ? process.argv[process.argv.indexOf("--out") + 1] : OUT_DEFAULT; if (!dstDir) throw new Error("--out requires a directory argument"); if (!existsSync(srcDir)) throw new Error(`no base extension at ${srcDir}`); console.log(`== ${srcDir} -> ${dstDir}`); mirrorTree(srcDir, dstDir); for (const rel of walk(dstDir)) { const p = join(dstDir, rel); if (!existsSync(p)) continue; if (rel.endsWith(".ts")) { let text = readFileSync(p, "utf8"); text = rewriteSpecifiers(text); if (FILE_RULES[rel]) text = applyOps(text, FILE_RULES[rel], rel); writeFileSync(p, text); } else if (rel === "package.json") { writeFileSync(p, transformPkg()); } else if (rel.endsWith(".md")) { let text = readFileSync(p, "utf8"); if (FILE_RULES[rel]) text = applyOps(text, FILE_RULES[rel], rel); if (rel.startsWith("agents/")) { text = applyOps(text, [AGENT_MD_FRONTMATTER, AGENT_MD_GLOB], rel); } writeFileSync(p, text); } } writeFileSync(join(dstDir, "tsconfig.json"), PYGENIUM_TSCONFIG); console.log("== bun install (regenerates bun.lock + node_modules)"); execSync("bun install", { cwd: dstDir, stdio: "inherit" }); console.log("== done"); } portExtension();