#!/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/pygienium (or
* --out) is a generated artifact. Every op asserts its target and fails
* loudly on base drift — never silently producing a stale port.
*
* The port is updated ONLY by CI on push (see below) — never by hand; local
* edits to ~/.omp/agent/extensions/pygienium are overwritten by the next run.
*
* CI: .gitea/workflows/port-to-omp.yml clones the omp-pygienium repo and
* runs this script into it (`bun port-to-omp.mjs --out
`), then
* commits + pushes when the port changed.
*/
import {
cpSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
realpathSync,
rmSync,
writeFileSync,
} from "node:fs";
import { join, relative, resolve, isAbsolute, dirname, basename } 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",
"release-tag.sh",
".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 real(p) {
try {
return realpathSync(p);
} catch {
// walk to the nearest existing ancestor and realpath it, then re-append
const tail = [];
let cur = resolve(p);
for (;;) {
try {
return join(realpathSync(cur), ...tail);
} catch {}
const parent = dirname(cur);
if (parent === cur) return resolve(p);
tail.unshift(basename(cur));
cur = parent;
}
}
}
function assertDstOutsideSrc(srcDir, dstDir) {
const rel = relative(real(srcDir), real(dstDir));
if (rel === "" || (!rel.startsWith("..") && !isAbsolute(rel))) {
throw new Error(
`refusing to port into a subdirectory of the source: ${dstDir} is inside ${srcDir}`
);
}
}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 README_STUB = `# Pygienium (omp port)
Code hygiene extension for omp — isolated sub-agent checks that scan a target, apply fixes, and emit a findings+changes report.
## Install
\`\`\`sh
omp install @mikefreno/omp-pygienium
\`\`\`
This is the omp port of [Mike/pygienium](https://git.freno.me/Mike/pygienium), regenerated automatically from the source repo. See the source repo for full documentation.
`;
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" }],
};
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", "pygienium");
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}`);
assertDstOutsideSrc(srcDir, dstDir);
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 === "README.md") {
writeFileSync(p, README_STUB);
} 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();