340 lines
14 KiB
JavaScript
340 lines
14 KiB
JavaScript
#!/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 <dir>`), 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.
|
|
`;
|
|
|
|
|
|
// publish workflow emitted into the port repo so the omp package can be
|
|
// released independently (tag push or manual dispatch on the omp repo).
|
|
const PORT_PUBLISH_WORKFLOW = "name: publish\n\n# Publish this omp port package to the npm registry on version tags.\n#\n# Prerequisites:\n# - npm automation token (publish scope) stored as the repo secret NPM_TOKEN\n# - package name claimed on npm (@mikefreno/omp-<name>)\n#\n# Manual publish: Actions tab → Run workflow (workflow_dispatch).\n\non:\n push:\n tags: ['v*']\n workflow_dispatch:\n\njobs:\n publish:\n runs-on: ubuntu-latest\n steps:\n - name: Checkout\n uses: actions/checkout@v4\n\n - name: Setup node (npm registry auth)\n uses: actions/setup-node@v4\n with:\n node-version: '22'\n registry-url: 'https://registry.npmjs.org'\n\n - name: Publish\n env:\n NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}\n run: npm publish --access public --ignore-scripts\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" }],
|
|
|
|
};
|
|
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);
|
|
const wfDir = join(dstDir, ".gitea", "workflows");
|
|
mkdirSync(wfDir, { recursive: true });
|
|
writeFileSync(join(wfDir, "publish.yml"), PORT_PUBLISH_WORKFLOW);
|
|
|
|
console.log("== bun install (regenerates bun.lock + node_modules)");
|
|
execSync("bun install", { cwd: dstDir, stdio: "inherit" });
|
|
console.log("== done");
|
|
}
|
|
|
|
portExtension();
|