mirror of
https://github.com/prdlk/dotfiles.git
synced 2026-11-02 19:32:43 +00:00
shell: fish functions -> ~/.local/bin executables + bash/zsh ports; manage ~/.agents
- 16 cross-shell utilities converted to sh/bash chezmoi executables: ai c2p cp-appid extract_agents fab gdc gh-sonr-org-migrate ghg-clone ghn gho git-summary launch_omp_livediff_overlay lc-elir mdcp mdp pj (ghg-clone not named ghg: unrelated cargo binary owns that name) - cwd/history helpers stay per-shell: yy c cd clip-last + ghg/ghn/gho cd-wrappers in bash/functions/ and zsh/functions/, loaded by rc files - fish keeps only shell-state functions; converted ones now hit PATH - cp-appid ignore gating moved to .local/bin path - dot_agents/skills: 82 skills materialized from dotstate storage so dot_claude skill symlinks resolve on fresh bootstrap; dotstate profile/symlinks.json released .agents/skills .bashrc .zshrc
This commit is contained in:
1 parent
6776366ce6
commit
6406208b35
637 files changed
+100297
-497
No files matched your search
@@ -0,0 +1,478 @@
|
||||
#!/usr/bin/env node
|
||||
// mintlify-codemod.mjs — deterministic, idempotent frontmatter codemod for the
|
||||
// Mintlify → Blume migration. It rewrites ONLY the YAML frontmatter block of
|
||||
// `.md`/`.mdx` files; the body is never touched. Two passes:
|
||||
//
|
||||
// 1. Icons — remap Mintlify (FontAwesome) `icon:` names to their closest
|
||||
// Lucide equivalent (Blume is Lucide-only). Brand/no-equivalent
|
||||
// icons are dropped and reported, never faked.
|
||||
// 2. Fields — drop frontmatter keys Blume's strict schema rejects, and rename
|
||||
// Mintlify-only keys to their Blume nesting (sidebarTitle →
|
||||
// sidebar.label, tag → sidebar.badge, canonical → seo.canonical,
|
||||
// og:image → seo.image). Ambiguous keys (openapi/asyncapi/api)
|
||||
// are flagged for human review, not transformed.
|
||||
//
|
||||
// Design constraints:
|
||||
// - ZERO dependencies — safe in a pnpm-strict workspace with no hoisting; runs
|
||||
// with a bare `node`, needs nothing from the target repo's node_modules.
|
||||
// - Deterministic — no Date/Math.random; files processed in sorted order.
|
||||
// - Idempotent — running twice makes no further change. Source keys are gone
|
||||
// after the first pass; remapped icon values are already Lucide (every
|
||||
// mapped value that is also a source key maps to itself); a rename whose
|
||||
// target already exists is reported, not re-applied.
|
||||
// - Surgical — untouched lines keep their exact formatting; only the specific
|
||||
// lines that change are edited, so diffs stay small and reviewable.
|
||||
// - Reports every change per file (and every drop/flag), so nothing is silent.
|
||||
//
|
||||
// Usage:
|
||||
// node mintlify-codemod.mjs <path...> # dry run — report only
|
||||
// node mintlify-codemod.mjs --write <path...> # apply changes in place
|
||||
// node mintlify-codemod.mjs --json <path...> # machine-readable report
|
||||
// node mintlify-codemod.mjs --help
|
||||
//
|
||||
// Paths may be files or directories; directories are walked for .md/.mdx.
|
||||
|
||||
import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
// --- Mintlify (FontAwesome) → Lucide icon map ------------------------------
|
||||
// Keys are lowercased Mintlify icon names; values are Lucide names. A value of
|
||||
// `null` means "no Lucide equivalent" (brand icons, mostly) — the icon line is
|
||||
// removed and reported. Every value that is itself a key maps to the same name,
|
||||
// which keeps the pass idempotent (a second run finds nothing to change). The
|
||||
// FontAwesome `x` (close) maps to Lucide `x`; the X/Twitter brand is
|
||||
// `x-twitter` and has no Lucide form.
|
||||
const ICONS = {
|
||||
angular: null,
|
||||
apple: null,
|
||||
"arrow-right-from-bracket": "log-out",
|
||||
aws: null,
|
||||
bell: "bell",
|
||||
bolt: "zap",
|
||||
book: "book",
|
||||
"book-open": "book-open",
|
||||
boxes: "boxes",
|
||||
calendar: "calendar",
|
||||
"chart-column": "chart-column",
|
||||
"chart-line": "chart-line",
|
||||
"chart-simple": "chart-column",
|
||||
check: "check",
|
||||
"circle-check": "circle-check",
|
||||
"circle-exclamation": "circle-alert",
|
||||
"circle-info": "info",
|
||||
"circle-question": "circle-help",
|
||||
cloud: "cloud",
|
||||
code: "code",
|
||||
cog: "settings",
|
||||
comments: "messages-square",
|
||||
copy: "copy",
|
||||
cube: "box",
|
||||
cubes: "boxes",
|
||||
database: "database",
|
||||
"diagram-project": "workflow",
|
||||
discord: null,
|
||||
docker: null,
|
||||
download: "download",
|
||||
envelope: "mail",
|
||||
facebook: null,
|
||||
"file-lines": "file-text",
|
||||
filter: "filter",
|
||||
flask: "flask-conical",
|
||||
folder: "folder",
|
||||
gauge: "gauge",
|
||||
"gauge-high": "gauge",
|
||||
gear: "settings",
|
||||
github: null,
|
||||
gitlab: null,
|
||||
globe: "globe",
|
||||
google: null,
|
||||
heart: "heart",
|
||||
house: "house",
|
||||
info: "info",
|
||||
instagram: null,
|
||||
java: null,
|
||||
js: null,
|
||||
key: "key",
|
||||
"layer-group": "layers",
|
||||
layers: "layers",
|
||||
"life-ring": "life-buoy",
|
||||
link: "link",
|
||||
linkedin: null,
|
||||
"location-dot": "map-pin",
|
||||
lock: "lock",
|
||||
magic: "sparkles",
|
||||
"magnifying-glass": "search",
|
||||
"map-marker": "map-pin",
|
||||
medium: null,
|
||||
microsoft: null,
|
||||
node: null,
|
||||
"node-js": null,
|
||||
npm: null,
|
||||
"pen-to-square": "square-pen",
|
||||
php: null,
|
||||
play: "play",
|
||||
puzzle: "puzzle",
|
||||
"puzzle-piece": "puzzle",
|
||||
python: null,
|
||||
question: "circle-help",
|
||||
react: null,
|
||||
"right-to-bracket": "log-in",
|
||||
robot: "bot",
|
||||
rocket: "rocket",
|
||||
rust: null,
|
||||
"screwdriver-wrench": "wrench",
|
||||
search: "search",
|
||||
server: "server",
|
||||
settings: "settings",
|
||||
shield: "shield",
|
||||
"shield-halved": "shield",
|
||||
sitemap: "network",
|
||||
slack: null,
|
||||
sparkles: "sparkles",
|
||||
star: "star",
|
||||
stripe: null,
|
||||
tag: "tag",
|
||||
telegram: null,
|
||||
terminal: "terminal",
|
||||
times: "x",
|
||||
toolbox: "wrench",
|
||||
"trash-can": "trash-2",
|
||||
"triangle-exclamation": "triangle-alert",
|
||||
twitter: null,
|
||||
upload: "upload",
|
||||
user: "user",
|
||||
users: "users",
|
||||
vuejs: null,
|
||||
"wand-magic-sparkles": "sparkles",
|
||||
whatsapp: null,
|
||||
workflow: "workflow",
|
||||
wrench: "wrench",
|
||||
x: "x",
|
||||
"x-twitter": null,
|
||||
xmark: "x",
|
||||
youtube: null,
|
||||
zap: "zap",
|
||||
};
|
||||
|
||||
// --- Frontmatter field policy ----------------------------------------------
|
||||
// Top-level keys Blume's strict schema rejects: delete the whole block, report.
|
||||
const DROP = new Set([
|
||||
"groups",
|
||||
"hideApiMarker",
|
||||
"hideFooterPagination",
|
||||
"iconType",
|
||||
"keywords",
|
||||
"mode",
|
||||
"public",
|
||||
"rss",
|
||||
]);
|
||||
|
||||
// Mintlify-only keys → Blume nested target. `[parent, child]`.
|
||||
const RENAME = {
|
||||
canonical: ["seo", "canonical"],
|
||||
"og:image": ["seo", "image"],
|
||||
ogImage: ["seo", "image"],
|
||||
sidebarTitle: ["sidebar", "label"],
|
||||
tag: ["sidebar", "badge"],
|
||||
};
|
||||
|
||||
// Keys we deliberately do NOT auto-transform — they usually mean the page is an
|
||||
// OpenAPI endpoint stub that should be deleted (Blume generates operation pages)
|
||||
// or converted to `type: api`. Flag for the human; never guess.
|
||||
const FLAG = new Set(["api", "asyncapi", "openapi"]);
|
||||
|
||||
// Which change kinds actually edit the file. Report-only kinds (flags,
|
||||
// unknowns, conflicts, manual-rename notices) leave the bytes untouched.
|
||||
const MUTATING = new Set(["drop", "icon-drop", "icon-remap", "rename"]);
|
||||
|
||||
// --- Frontmatter line model -------------------------------------------------
|
||||
|
||||
// Split a file into { fm, body, eol } or null when there's no `---` block.
|
||||
const splitFrontmatter = (text) => {
|
||||
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
||||
const lines = text.split(/\r?\n/u);
|
||||
if (lines[0] !== "---") {
|
||||
return null;
|
||||
}
|
||||
let end = -1;
|
||||
for (let i = 1; i < lines.length; i += 1) {
|
||||
if (lines[i] === "---") {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (end === -1) {
|
||||
return null;
|
||||
}
|
||||
return { body: lines.slice(end + 1), eol, fm: lines.slice(1, end) };
|
||||
};
|
||||
|
||||
// Match a top-level (column-0) `key:` line, honoring quoted keys. Because the
|
||||
// key class allows `:`, backtracking resolves `og:image: x` to key `og:image`.
|
||||
const topKey = (line) => {
|
||||
const m = /^(?<q>["']?)(?<key>[^"'\s:][^"']*?)\k<q>\s*:(?<rest>\s.*|)$/u.exec(
|
||||
line
|
||||
);
|
||||
if (!m || /^\s/u.test(line)) {
|
||||
return null;
|
||||
}
|
||||
return { key: m.groups.key, value: (m.groups.rest ?? "").trim() };
|
||||
};
|
||||
|
||||
// The line index range [start, endExclusive) of a top-level key's block.
|
||||
const blockRange = (fm, start) => {
|
||||
let end = start + 1;
|
||||
while (end < fm.length && (fm[end] === "" || /^\s/u.test(fm[end]))) {
|
||||
end += 1;
|
||||
}
|
||||
// Trim trailing blank lines back out so the gap before the next key survives.
|
||||
while (end - 1 > start && fm[end - 1] === "") {
|
||||
end -= 1;
|
||||
}
|
||||
return [start, end];
|
||||
};
|
||||
|
||||
// Does the block starting at `start` carry a nested child `childKey:`?
|
||||
const findChild = (fm, start, endExclusive, childKey) => {
|
||||
for (let i = start + 1; i < endExclusive; i += 1) {
|
||||
const m = /^\s+(?<q>["']?)(?<key>[^"'\s:][^"']*?)\k<q>\s*:/u.exec(fm[i]);
|
||||
if (m && m.groups.key === childKey) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
// --- The two passes ---------------------------------------------------------
|
||||
|
||||
// Remap `icon:` values in place. Mutates `fm`; returns change records.
|
||||
const remapIcons = (fm) => {
|
||||
const changes = [];
|
||||
let i = 0;
|
||||
while (i < fm.length) {
|
||||
const m =
|
||||
/^(?<indent>\s*)icon:\s*(?<q>["']?)(?<name>[^"'#]*?)\k<q>\s*(?<comment>#.*)?$/u.exec(
|
||||
fm[i]
|
||||
);
|
||||
if (!m) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
const raw = m.groups.name.trim();
|
||||
// Empty, an object, or a template ref — leave it for the human.
|
||||
if (raw === "" || raw.startsWith("{") || raw.startsWith("$")) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
const key = raw.toLowerCase();
|
||||
if (!(key in ICONS)) {
|
||||
changes.push({ detail: raw, kind: "icon-unknown" });
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
const lucide = ICONS[key];
|
||||
if (lucide === null) {
|
||||
changes.push({ detail: raw, kind: "icon-drop" });
|
||||
fm.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
if (lucide !== raw) {
|
||||
const comment = m.groups.comment ? ` ${m.groups.comment}` : "";
|
||||
fm[i] = `${m.groups.indent}icon: ${lucide}${comment}`;
|
||||
changes.push({ detail: `${raw} → ${lucide}`, kind: "icon-remap" });
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
return changes;
|
||||
};
|
||||
|
||||
// Insert `child: value` under `parent`, creating the parent block if needed.
|
||||
const setNested = (fm, parent, child, value) => {
|
||||
for (let i = 0; i < fm.length; i += 1) {
|
||||
const tk = topKey(fm[i]);
|
||||
if (!tk || tk.key !== parent) {
|
||||
continue;
|
||||
}
|
||||
if (tk.value !== "") {
|
||||
// `parent: scalar` — can't nest under a scalar without clobbering it.
|
||||
return { ok: false, reason: "parent-is-scalar" };
|
||||
}
|
||||
const [start, end] = blockRange(fm, i);
|
||||
if (findChild(fm, start, end, child) !== -1) {
|
||||
return { ok: false, reason: "child-exists" };
|
||||
}
|
||||
fm.splice(start + 1, 0, ` ${child}: ${value}`);
|
||||
return { index: start + 1, ok: true };
|
||||
}
|
||||
// No parent block — append one at the end of the frontmatter.
|
||||
const index = fm.length;
|
||||
fm.push(`${parent}:`, ` ${child}: ${value}`);
|
||||
return { index, ok: true };
|
||||
};
|
||||
|
||||
// Drop unsupported keys, rename Mintlify-only keys, flag ambiguous ones.
|
||||
const rewriteFields = (fm) => {
|
||||
const changes = [];
|
||||
// Walk from the bottom so splices don't shift indices we haven't visited.
|
||||
for (let i = fm.length - 1; i >= 0; i -= 1) {
|
||||
const tk = topKey(fm[i]);
|
||||
if (!tk) {
|
||||
continue;
|
||||
}
|
||||
if (DROP.has(tk.key)) {
|
||||
const [start, end] = blockRange(fm, i);
|
||||
fm.splice(start, end - start);
|
||||
changes.push({ detail: tk.key, kind: "drop" });
|
||||
continue;
|
||||
}
|
||||
if (FLAG.has(tk.key)) {
|
||||
changes.push({ detail: tk.key, kind: "flag" });
|
||||
continue;
|
||||
}
|
||||
const target = RENAME[tk.key];
|
||||
if (!target) {
|
||||
continue;
|
||||
}
|
||||
const [parent, child] = target;
|
||||
const [start, end] = blockRange(fm, i);
|
||||
if (end - start !== 1 || tk.value === "") {
|
||||
// Multi-line or valueless source — too structured to move safely.
|
||||
changes.push({
|
||||
detail: `${tk.key} → ${parent}.${child}`,
|
||||
kind: "rename-manual",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Remove the source line before inserting: setNested splices into the
|
||||
// parent block, and when that block sits above the source key the insert
|
||||
// would otherwise shift `start` onto the wrong line. Failure paths don't
|
||||
// mutate `fm`, so the line can be restored as-is on conflict.
|
||||
const [removed] = fm.splice(start, 1);
|
||||
const placed = setNested(fm, parent, child, tk.value);
|
||||
if (placed.ok) {
|
||||
if (placed.index < start) {
|
||||
// The insert above the cursor pushed the unvisited lines down one
|
||||
// slot; revisit the current index so none of them get skipped.
|
||||
i += 1;
|
||||
}
|
||||
changes.push({
|
||||
detail: `${tk.key} → ${parent}.${child}`,
|
||||
kind: "rename",
|
||||
});
|
||||
} else {
|
||||
fm.splice(start, 0, removed);
|
||||
// Target already set, or parent is a scalar — leave the source in place so
|
||||
// no data is lost, and report it for manual resolution.
|
||||
changes.push({
|
||||
detail: `${tk.key} → ${parent}.${child} (${placed.reason})`,
|
||||
kind: "rename-conflict",
|
||||
});
|
||||
}
|
||||
}
|
||||
return changes.toReversed();
|
||||
};
|
||||
|
||||
// --- Driver -----------------------------------------------------------------
|
||||
|
||||
const transform = (text) => {
|
||||
const split = splitFrontmatter(text);
|
||||
if (!split) {
|
||||
return { changed: false, changes: [], text };
|
||||
}
|
||||
const fm = [...split.fm];
|
||||
const changes = [...remapIcons(fm), ...rewriteFields(fm)];
|
||||
const rebuilt = ["---", ...fm, "---", ...split.body].join(split.eol);
|
||||
const changed = rebuilt !== text && changes.some((c) => MUTATING.has(c.kind));
|
||||
return { changed, changes, text: changed ? rebuilt : text };
|
||||
};
|
||||
|
||||
const collectFiles = (paths) => {
|
||||
const out = [];
|
||||
const walk = (p) => {
|
||||
const st = statSync(p);
|
||||
if (st.isDirectory()) {
|
||||
for (const name of readdirSync(p).toSorted()) {
|
||||
if (name === "node_modules" || name.startsWith(".")) {
|
||||
continue;
|
||||
}
|
||||
walk(path.join(p, name));
|
||||
}
|
||||
} else if (/\.mdx?$/u.test(p)) {
|
||||
out.push(p);
|
||||
}
|
||||
};
|
||||
for (const p of paths.toSorted()) {
|
||||
walk(p);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const LABEL = {
|
||||
drop: "dropped",
|
||||
flag: "FLAG (review)",
|
||||
"icon-drop": "icon dropped (no Lucide equivalent)",
|
||||
"icon-remap": "icon",
|
||||
"icon-unknown": "icon unknown (verify at lucide.dev)",
|
||||
rename: "renamed",
|
||||
"rename-conflict": "rename conflict (left in place)",
|
||||
"rename-manual": "rename needs manual edit",
|
||||
};
|
||||
|
||||
const HELP = [
|
||||
"mintlify-codemod — icon + frontmatter pass for Mintlify → Blume",
|
||||
"",
|
||||
" node mintlify-codemod.mjs <path...> dry run (report only)",
|
||||
" node mintlify-codemod.mjs --write <path...> apply in place",
|
||||
" node mintlify-codemod.mjs --json <path...> JSON report",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
const main = () => {
|
||||
const argv = process.argv.slice(2);
|
||||
if (argv.includes("--help") || argv.includes("-h") || argv.length === 0) {
|
||||
process.stdout.write(`${HELP}\n`);
|
||||
return;
|
||||
}
|
||||
const write = argv.includes("--write");
|
||||
const asJson = argv.includes("--json");
|
||||
const paths = argv.filter((a) => !a.startsWith("--"));
|
||||
const files = collectFiles(paths);
|
||||
|
||||
const report = [];
|
||||
let changedCount = 0;
|
||||
for (const file of files) {
|
||||
const before = readFileSync(file, "utf-8");
|
||||
const { changed, changes, text } = transform(before);
|
||||
if (changes.length === 0) {
|
||||
continue;
|
||||
}
|
||||
if (changed && write) {
|
||||
writeFileSync(file, text, "utf-8");
|
||||
}
|
||||
if (changed) {
|
||||
changedCount += 1;
|
||||
}
|
||||
report.push({ changes, file: path.relative(process.cwd(), file) });
|
||||
}
|
||||
|
||||
if (asJson) {
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({ files: report, wrote: write }, null, 2)}\n`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of report) {
|
||||
process.stdout.write(`\n${entry.file}\n`);
|
||||
for (const c of entry.changes) {
|
||||
process.stdout.write(` ${LABEL[c.kind] ?? c.kind}: ${c.detail}\n`);
|
||||
}
|
||||
}
|
||||
const verb = write ? "changed" : "would change";
|
||||
const hint = write ? "" : " Re-run with --write to apply.";
|
||||
process.stdout.write(
|
||||
`\n${report.length} file(s) with findings, ${changedCount} ${verb}.${hint}\n`
|
||||
);
|
||||
};
|
||||
|
||||
main();
|
||||
Reference in new issue
Block a user