Skip to content

Instantly share code, notes, and snippets.

@erukiti
Created October 21, 2025 09:00
Show Gist options
  • Select an option

  • Save erukiti/f6caafc6224359a634326c890a34fdc5 to your computer and use it in GitHub Desktop.

Select an option

Save erukiti/f6caafc6224359a634326c890a34fdc5 to your computer and use it in GitHub Desktop.
Macのように、人間はzshを使って、Codex/Claude Codeはbashを使うみたいな環境で、実行するものの差分が生じるするのを検出するスクリプト。bun shell-drift-check.ts みたいに実行して
/**
* Shell Drift Checker
*
* Compare environment differences between `bash -lc` (Codex default) and `zsh -lc` (typical human shell).
*
* Usage:
* bun shell-drift-check.ts [--interactive] [--json] [--cmds node,bun,npm,yarn,pnpm,deno,python3,go,jq,rg]
*
* Notes:
* - By default, prints a concise human-readable report without dumping all env values to avoid leaking secrets.
* - Focuses on PATH order and command resolution for common toolchain binaries.
* - Use `--json` to get a machine-readable summary (no secret values included).
*/
type Shell = 'bash' | 'zsh';
const DEFAULT_CMDS = [
'node',
'npm',
'bun',
'yarn',
'pnpm',
'deno',
'python3',
'go',
'jq',
'rg',
'awk',
'sed',
'grep',
];
const argv = new Set(process.argv.slice(2));
const interactive = argv.has('--interactive');
const jsonOut = argv.has('--json');
const cmdsArg = [...argv].find((a) => a.startsWith('--cmds='));
const CMDS = cmdsArg
? cmdsArg.replace(/^--cmds=/, '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
: DEFAULT_CMDS;
function sh(shell: Shell, code: string): Promise<{ stdout: string; stderr: string; exitCode: number }>{
const flags = interactive ? ['-lic'] : ['-lc'];
const p = Bun.spawn([shell, ...flags, code], { stdio: ['ignore', 'pipe', 'pipe'] });
return Promise.all([p.stdout?.text() ?? Promise.resolve(''), p.stderr?.text() ?? Promise.resolve('')]).then(
([stdout, stderr]) => ({ stdout, stderr, exitCode: p.exitCode })
);
}
async function getPathList(shell: Shell): Promise<string[]> {
const { stdout } = await sh(shell, "printf '%s' \"$PATH\" | tr ':' '\n'");
return stdout.split('\n').filter(Boolean);
}
async function getAliases(shell: Shell): Promise<Record<string, string>> {
const { stdout } = await sh(shell, 'alias');
const out: Record<string, string> = {};
for (const line of stdout.split('\n')) {
// Expected: alias ll='ls -l'
const m = line.match(/^alias\s+([^=]+)=(.*)$/);
if (!m) continue;
const name = m[1].trim();
let rhs = m[2].trim();
// strip one layer of quotes if present
if ((rhs.startsWith("'") && rhs.endsWith("'")) || (rhs.startsWith('"') && rhs.endsWith('"'))) {
rhs = rhs.slice(1, -1);
}
out[name] = rhs;
}
return out;
}
async function which(shell: Shell, cmd: string): Promise<string | null> {
const { stdout, exitCode } = await sh(shell, `command -v ${cmd} 2>/dev/null || true`);
const p = stdout.trim();
return exitCode === 0 && p ? p : p || null;
}
async function version(shell: Shell, cmd: string): Promise<string | null> {
// Try common version flags conservatively
const attempts = [
`${cmd} --version`,
`${cmd} -V`,
`${cmd} -v`,
];
for (const a of attempts) {
const { stdout, stderr, exitCode } = await sh(shell, `${a} 2>/dev/null || true`);
const out = (stdout || stderr).trim();
if (exitCode === 0 && out) return out.split('\n')[0];
if (out) return out.split('\n')[0];
}
return null;
}
function diffOrderedPath(a: string[], b: string[]) {
// Returns order-aware differences
const max = Math.max(a.length, b.length);
const rows: { idx: number; bash?: string; zsh?: string; same: boolean }[] = [];
for (let i = 0; i < max; i++) {
const ra = a[i];
const rb = b[i];
rows.push({ idx: i, bash: ra, zsh: rb, same: ra === rb });
}
const firstMismatch = rows.find((r) => !r.same);
// Set differences (regardless of order)
const setA = new Set(a);
const setB = new Set(b);
const onlyA = a.filter((p) => !setB.has(p));
const onlyB = b.filter((p) => !setA.has(p));
return { rows, firstMismatchIndex: firstMismatch?.idx ?? -1, onlyA, onlyB };
}
async function collect(shell: Shell) {
const [pathList, aliases, whichMap, versionMap] = await Promise.all([
getPathList(shell),
getAliases(shell),
(async () => {
const map: Record<string, string | null> = {};
await Promise.all(
CMDS.map(async (c) => {
map[c] = await which(shell, c);
})
);
return map;
})(),
(async () => {
const map: Record<string, string | null> = {};
await Promise.all(
CMDS.map(async (c) => {
// Version call only if command exists
const w = await which(shell, c);
map[c] = w ? await version(shell, c) : null;
})
);
return map;
})(),
]);
return { shell, pathList, aliases, which: whichMap, versions: versionMap };
}
function formatReport(bashInfo: Awaited<ReturnType<typeof collect>>, zshInfo: Awaited<ReturnType<typeof collect>>) {
const pathDiff = diffOrderedPath(bashInfo.pathList, zshInfo.pathList);
const driftFlags: string[] = [];
if (pathDiff.firstMismatchIndex !== -1 || pathDiff.onlyA.length || pathDiff.onlyB.length) {
driftFlags.push('PATH');
}
const cmdRows: { name: string; bashPath: string | null; zshPath: string | null; bashVer: string | null; zshVer: string | null; same: boolean }[] = [];
for (const c of CMDS) {
const bashPath = bashInfo.which[c] ?? null;
const zshPath = zshInfo.which[c] ?? null;
const bashVer = bashInfo.versions[c] ?? null;
const zshVer = zshInfo.versions[c] ?? null;
const same = Boolean(bashPath && zshPath && bashPath === zshPath && bashVer && zshVer && bashVer === zshVer);
if (!same) driftFlags.push(`cmd:${c}`);
cmdRows.push({ name: c, bashPath, zshPath, bashVer, zshVer, same });
}
const aliasNamesB = new Set(Object.keys(bashInfo.aliases));
const aliasNamesZ = new Set(Object.keys(zshInfo.aliases));
const aliasOnlyB = [...aliasNamesB].filter((a) => !aliasNamesZ.has(a));
const aliasOnlyZ = [...aliasNamesZ].filter((a) => !aliasNamesB.has(a));
if (aliasOnlyB.length || aliasOnlyZ.length) driftFlags.push('alias');
const summary = {
driftDetected: driftFlags.length > 0,
driftAreas: [...new Set(driftFlags)],
firstPathMismatchIndex: pathDiff.firstMismatchIndex,
};
if (jsonOut) {
const json = {
summary,
bash: {
path: bashInfo.pathList,
aliases: Object.keys(bashInfo.aliases),
which: bashInfo.which,
versions: bashInfo.versions,
},
zsh: {
path: zshInfo.pathList,
aliases: Object.keys(zshInfo.aliases),
which: zshInfo.which,
versions: zshInfo.versions,
},
pathDiff: {
onlyInBash: pathDiff.onlyA,
onlyInZsh: pathDiff.onlyB,
firstMismatchIndex: pathDiff.firstMismatchIndex,
},
commands: cmdRows,
};
console.log(JSON.stringify(json, null, 2));
return summary.driftDetected ? 1 : 0;
}
// Human-readable report
const lines: string[] = [];
lines.push('=== Shell Drift Report ===');
lines.push(`Mode: ${interactive ? 'login+interactive' : 'login'} shells (bash -l${interactive ? 'i' : ''}c / zsh -l${interactive ? 'i' : ''}c)`);
lines.push(`Drift: ${summary.driftDetected ? 'DETECTED' : 'none'}`);
lines.push('');
lines.push('- PATH order & membership:');
if (pathDiff.firstMismatchIndex === -1 && pathDiff.onlyA.length === 0 && pathDiff.onlyB.length === 0) {
lines.push(' identical');
} else {
if (pathDiff.firstMismatchIndex !== -1) {
lines.push(` first mismatch index: ${pathDiff.firstMismatchIndex}`);
}
if (pathDiff.onlyA.length) {
lines.push(' only in bash:');
for (const p of pathDiff.onlyA) lines.push(` ${p}`);
}
if (pathDiff.onlyB.length) {
lines.push(' only in zsh:');
for (const p of pathDiff.onlyB) lines.push(` ${p}`);
}
}
lines.push('');
lines.push('- Command resolution & versions:');
for (const r of cmdRows) {
const icon = r.same ? '✓' : '✗';
lines.push(` ${icon} ${r.name}`);
lines.push(` bash: ${r.bashPath ?? '-'} ${r.bashVer ? `(${r.bashVer})` : ''}`);
lines.push(` zsh : ${r.zshPath ?? '-'} ${r.zshVer ? `(${r.zshVer})` : ''}`);
}
lines.push('');
lines.push('- Alias names (names only, values omitted):');
if (!aliasOnlyB.length && !aliasOnlyZ.length) {
lines.push(' identical set of alias names');
} else {
if (aliasOnlyB.length) {
lines.push(' only in bash: ' + aliasOnlyB.join(', '));
}
if (aliasOnlyZ.length) {
lines.push(' only in zsh : ' + aliasOnlyZ.join(', '));
}
}
console.log(lines.join('\n'));
return summary.driftDetected ? 1 : 0;
}
async function main() {
const [bashInfo, zshInfo] = await Promise.all([collect('bash'), collect('zsh')]);
const code = formatReport(bashInfo, zshInfo);
process.exit(code);
}
main().catch((err) => {
console.error('[shell-drift-check] fatal:', err);
process.exit(2);
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment