Codex Setup

SkillAI & models

Initialize sd0x-dev-flow infrastructure for Codex CLI and other non-Claude agents. Generates AGENTS.md, installs the commit-msg hook, copies runner scripts. The pre-push gate is opt-in via --with-push-gate. Use when setting up a new project or after updating skills.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Codex Setup skill

What this skill tells your AI

The instructions your AI receives, as published by sd0xdev/sd0x-harness in skills/codex-setup/SKILL.md and read by ahel’s review.

Trigger

  • Keywords: codex setup, codex init, agents.md, setup codex, initialize codex, codex doctor, codex sync
  • After: npx skills add sd0xdev/sd0x-harness

Subcommands

CommandPurpose
initFirst-time setup: generate AGENTS.md + install the commit-msg hook + copy scripts
doctorVerify installation integrity: files exist, AGENTS.md hash matches, and each recorded hook is active — hook bytes are sync's axis, not this one (§ doctor)
syncRe-generate AGENTS.md + update installed hooks/scripts after skill update

Default (no subcommand): init

Arguments

FlagApplies toEffect
--with-push-gateinit, syncInstall pre-push-gate.sh as the pre-push hook. Off by default

The pre-push gate is the one hook that waits for a human — it reads /dev/tty, so from a non-interactive context it stalls or fails on a terminal that is not there. That is why it is opt-in, and why every path below reads that choice from state rather than re-deciding it. commit-msg stays a default install because it never prompts: it guards the attribution anchor (CLAUDE.md rule 3) by reading the message and deciding, with no /dev/tty and no input. It does still reject — exit 1 on a policy violation, exit 3 when the policy cannot be evaluated — and it rejects interactive and non-interactive commits alike (scripts/commit-msg-guard.sh). The distinction that makes it safe to install by default is prompts vs. rejects, not blocks vs. does not block.

The flag is the opt-in interface — there is no prompt. init must not ask interactively whether to install the gate: this skill runs under Codex sandboxes and in non-interactive setup flows where an unanswered prompt would either hang or be silently defaulted, and a silent default is exactly what opt-in exists to prevent.

init

Phase 1: Detect Host Context

  1. Find repo root: git rev-parse --show-toplevel
  2. Read package.json if present → extract name, scripts.test
  3. Read .claude/CLAUDE.md or CLAUDE.md → extract test command pattern
  4. Detect plugin root: find scripts/build-codex-artifacts.js relative to this skill

Phase 2: Generate AGENTS.md Kernel

node <plugin-root>/scripts/build-codex-artifacts.js \
  --project-dir <repo-root> \
  --output <repo-root>/AGENTS.md

If the file already exists, warn and ask before overwriting.

Verify output:

  • File exists and is non-empty
  • Size ≤ 24 KiB (wc -c < AGENTS.md ≤ 24576)
  • No unresolved placeholders ({PROJECT_NAME}, {VERSION}, {TEST_COMMAND})

Phase 3: Multi-Mode Hook Install

Install the git hooks using priority-ordered detection. The mode detection is identical for both hooks; only which hooks are installed differs:

PriorityConditionAction
1.husky/ directory existsCopy the hook's script to .claude/scripts/, then prepend that hook's executing stanza below to .husky/<hook>commit-msg-guard.sh into .husky/commit-msg, and (only under --with-push-gate) pre-push-gate.sh into .husky/pre-push. Two stanzas, not one: they are shaped by what git hands each hook, and § The Husky commit-msg stanza says where they differ
2git config core.hooksPath is setInstall to that path
3.git/hooks/ is writableDirect write
4FallbackWrite to .githooks/ + print git config core.hooksPath .githooksthe write alone does not arm the hook; see below
Modes 2–4 write the hook as the file, so check who owns it first

Mode 1 prepends into a shared container; modes 2–4 put the gate — or the guard — at the resolved hook path as that file's whole content. A write there is a delete of whatever was there, and the operator's own pre-push may be the only thing standing between them and something this skill knows nothing about. --with-push-gate is a request to add a guard; taking one away to honour it is not a lesser reading of that request, it is the opposite of it.

So before any dedicated-file mode writes a hook, classify the destination — by content, never by existence:

DestinationAction
Absent, or present and emptyWrite. Nothing is lost
sd0x-owned — one of the first 20 lines is exactly # <script> - followed by that script's own summary, where <script> is pre-push-gate.sh or commit-msg-guard.sh per hookOverwrite. This is a refresh of our own file, which is what sync exists to do
Anything elseDo not write. Record pending, and report: <hook>: <resolved> already exists and is not sd0x-owned — the gate was not installed; move or rename that hook, then re-run

The marker is the shipped scripts' own second line, so an older shipped version is still recognised as ours and a refresh across versions is not blocked. It is matched as a prefix of a line rather than against the whole file because the byte-for-byte alternative refuses every past version — the one case a refresh most needs to succeed on.

The predicate is deliberately strict in one direction. A hand-edited header stops matching and the install refuses; a foreign file matches only if someone copied our header into it. Refusing an install costs a re-run, and the operator is told which file to move; clobbering costs a hook nobody can get back. The asymmetry decides, exactly as it does for uninstall below — that row already said "only after verifying it is sd0x-owned", and this is the same predicate, stated once, on the side where the file is still there to protect.

pending is the honest record for a refused write: the operator opted in, the wiring did not finish, and the transition matrix already treats pending as a gate to keep — so a later sync retries rather than reading the refusal as a decline. Recording declined would turn their request into an opt-out they never made.

The Husky stanza — prepended, executed, and it hands stdin back

Mode 1 does not write its own file: it goes into .husky/<hook>, beside whatever the project already put there. Two properties of that neighbourhood decide the shape of the stanza, and both were got wrong by the words this section used to carry ("append sourcing"):

PropertyConsequenceMeasured 2026-08-21
git delivers the ref list on stdin, onceAn earlier consumer in the same hook takes it, and the gate then reads EOF — zero refs, nothing protected, nothing rewritten, exit 0An existing while read … loop ahead of the gate turned a protected-branch rewrite that the gate refuses with exit 1 into an allowed push
./source does not change $0The gate's privileged re-exec, which then read exec … bash -p "$0" "$@", named the Husky hook rather than the gateThe parent hook's first line printed twice — once at $-=hB, once at hpB — i.e. the user's whole pre-push re-ran under a privileged shell, and the gate never ran as the gate. The gate has since switched that word to ${BASH_SOURCE[0]:-$0}, which resolves correctly under source, and an ordinary source of the gate now refuses (2026-08-22) — so no supported caller reaches this defect. Not "closed entirely": the refusal compares against $0, which a sourcing caller supplies, and a forged one walks past it (4-implementation.md § 4.3, Round 54). Row 1 above was already reason enough on its own

So: prepend, and execute. Prepending alone would fix the first and break the project's own hook, which then reads the EOF instead — the same fail-open one file over. The stanza therefore captures the stream once and hands it back:

# >>> sd0x-dev-flow pre-push gate >>>
# No bare command word may decide anything or carry a refusal. This hook runs with the pusher's
# whole environment, and an exported BASH_FUNC_name%% answers `git`, `cat`, `bash`, `mktemp`,
# `[`, `test`, `exec` and `exit` alike. Three things are immune, and every construct here that
# runs BEFORE the verdict, or that enforces it, is built out of only those three:
# `case` (a reserved word, resolved by the grammar), `${x:?}` (fails during expansion, before
# command lookup), and an ABSOLUTE path — bash refuses to import a function whose name contains
# a slash. Everything else that runs before the verdict is inside `bash -p`, which imports no
# functions at all. The two bare words that DO appear — `exec 0<` and `rm -f` — sit after
# `__sd0x_rc` already holds the verdict, and what a shadowed one costs is stated under
# "Two residuals" below rather than wished away by an absolute claim here.
__sd0x_refs=$(/usr/bin/env -u SHELLOPTS -u BASHOPTS -u BASH_ENV -u ENV -u SD0X_PRIV_REEXEC \
  /bin/bash -p -c 'umask 077; f=$(mktemp) || exit 1; cat >"$f" || exit 1; printf %s "$f"')
case "$__sd0x_refs" in
  /*) ;;
  *) __sd0x_abort=''
     : "${__sd0x_abort:?sd0x pre-push gate: could not capture the ref stream}" ;;
esac
/usr/bin/env -u SHELLOPTS -u BASHOPTS -u BASH_ENV -u ENV -u SD0X_PRIV_REEXEC \
  /bin/bash -p -c 'test -r "$1" || exit 0; g=$1; r=$2; shift 2; exec "$BASH" -p -- "$g" "$@" <"$r"' \
  sd0x-pre-push ./.claude/scripts/pre-push-gate.sh "$__sd0x_refs" "$@"
__sd0x_rc=$?
exec 0< "$__sd0x_refs"        # hand git's one-shot stream back to the rest of the hook
rm -f "$__sd0x_refs"          # unlinked, still open on fd 0
case "$__sd0x_rc" in
  0) ;;
  *) __sd0x_abort=''
     : "${__sd0x_abort:?sd0x pre-push gate: refused this push}" ;;
esac
# <<< sd0x-dev-flow pre-push gate <<<

Why the shape is this and not the obvious one. The obvious stanza — __sd0x_gate="$(git rev-parse --show-toplevel)/…", [ -r … ], cat, bash "$gate", exit "$rc" — is what this section shipped for one round, and every command word in it is a hole. Measured 2026-08-21:

InjectedWhat the obvious stanza did
BASH_FUNC_git%%='() { echo /definitely-missing; }'resolved the gate to a path that does not exist, so [ -r … ] was false and the hook continued with no gate and exit 0
BASH_FUNC_cat%%='() { :; }'discarded the ref stream; the gate then read an empty file, found no refs, and exited 0
BASH_FUNC_bash%%='() { return 0; }'never launched the gate at all, and reported success
BASH_FUNC_exec%% / BASH_FUNC_exit%%exec and exit are builtins, so both are shadowable: the refusal path became a no-op

That is the same class the gate closes for itself with its privileged re-exec — the stanza simply sat in front of it, outside that protection, and handed the attacker every decision the gate was about to make. The replacement removes the decisions rather than hardening them:

ConstructWhy it cannot be intercepted
/usr/bin/env written out absolutelybash: error importing function definition for '/usr/bin/env' — the import is refused outright, and a bare env is shadowable (measured both ways). Narrower than it reads: a slash is illegal in an imported name, not in a defined one, so a definition sourced into this shell carries it fine (function /usr/bin/env { … } — measured). That leaves $BASH_ENV, sourced before the hook's first line, outside what this row covers; see the residual below
./.claude/scripts/pre-push-gate.sh — relative, no git rev-parsegit runs pre-push with the working tree root as cwd, measured to hold even when the push was issued from a subdirectory. A path needs no command to produce, so there is nothing to intercept
Everything real inside /bin/bash -p -c-p imports no BASH_FUNC_* and reads no $BASH_ENV, so mktemp, cat, test and the gate itself resolve normally
case for both decisionsA reserved word: the grammar resolves it, never command lookup
: "${x:?…}" for both refusalsExpansion fails before command lookup and kills a non-interactive shell (rc 127, measured with : and exit both shadowed). exit "$rc" could be answered; this cannot
exec "$BASH" -p -- "$g" rather than exec "$g"Does not depend on the gate carrying its executable bit, and does not resolve the gate's #!/usr/bin/env -S bash -p shebang through PATH. Supplying -p here is what keeps that bypassed shebang from mattering — the two must stay in step, and a -p dropped from either line reopens $BASH_ENV for this path
-u SD0X_PRIV_REEXECThe gate establishes privileged mode through that marker. Leaving an inherited one in place would make the gate skip its own re-exec — a bypass handed over by the caller

A third residual sits above all of them and is not this stanza's to close: these lines run in the project's own .husky/pre-push shell, whose shebang the project owns. If that shell is bash and the pusher's $BASH_ENV defines /usr/bin/env, the first line here is answered before bash -p is ever reached. The gate's own file closed the same hole for itself by putting -p in its shebang (scripts/pre-push-gate.sh, 2026-08-22) — the one statement early enough, and the one this stanza does not get to write.

Two further residuals, both stated rather than hidden, and both pinned by the hostile-environment test in test/skills/codex-setup.test.js rather than left as prose. A shadowed rm leaks one 0600 temp file per push and cannot open a gate. A shadowed exec leaves the rest of the project's hook reading an already-drained stream — measured TAIL-SAW 0 — because handing stdin back needs a redirection, redirection needs exec, and no POSIX construct reopens fd 0 without it. What that costs is bounded and is not this gate: ours has already decided and enforced by then, and what degrades is the project's own hook. A hook that refuses on an empty ref list still refuses; one that passes on it passes — which is the same exposure it already had before this stanza existed. PATH is outside this boundary for the reason § 4.3 of the implementation doc gives: it cannot be unset, has no trustworthy substitute, and a hostile one is a strictly larger compromise than anything the gate protects.

Line by line, each for a reason that has a failure behind it:

LineWhy not the shorter form
Executing the gate, never . "$gate"The gate refuses to be sourced — measured 2026-08-22: . ./scripts/pre-push-gate.sh prints pre-push-gate: must be executed, not sourced and takes the sourcing shell down with rc 127. So this line is not a preference the stanza expresses. It is not a defence, either — the refusal compares ${BASH_SOURCE[0]} against $0, and a sourcing caller chooses $0, so a forged one is not stopped (measured; 4-implementation.md § 4.3, Round 54). What it reliably catches is the accidental source in a hand-written wrapper, which is what this row is about. The reasons this row used to give are both retired: the $0 defect was fixed in the gate (${BASH_SOURCE[0]:-$0}), and the set -euo pipefail leak is unreachable — the refusal precedes the set, so sourcing never reaches the options it was said to leak (stated as an order rather than as line numbers, which the header migration had already moved once)
__sd0x_rc=$? — and __sd0x_cm_rc=$? in the commit-msg stanza, the same shape for the same reason — on its own lineTwo containers, one refusal. Under set -e the hook exits at the failing gate carrying the gate's own status, and the case below is never reached (the temp file leaks — a refused push, not an allowed one). Without set -e the assignment captures the status and the case refuses. Writing it as || __sd0x_rc=$? would put the gate in a condition context and suppress the first path, leaving only the second
exec 0< before rmReversed, the rest of the hook gets a closed descriptor. Unlink-after-open is what keeps the bytes reachable with nothing left on disk
test -r "$1" || exit 0 inside the privileged childAn uninstalled or half-removed gate must leave the push alone, so the project's hook behaves exactly as it did before. Install-time verification is what keeps that from silently covering a misinstalled gate — see the Active predicate below
Marker comments on their own linesThey are the detection and uninstall boundary (§ sync, § Uninstall). doctor's "sd0x stanza present" test greps for the opening marker — a presence probe, and nothing more. Every decision that can record installed reads the pair: an opening marker alone is also what a truncated stanza leaves behind, and § sync's marker matrix has a terminal answer for that case which "present" would silently overrule

Definition — an intact sd0x block, used by every test in this file that can record installed, so the three of them cannot drift apart. .husky/<hook> holds one when all of:

ClauseWhy it is not droppable
Exactly one opening marker and exactly one closing markerTwo of either gives the block two candidate boundaries, and replacing one leaves the other running
The opening precedes the closingSame counts, no block — the markers bound nothing, and "count is right" would certify it
The lines between them invoke .claude/scripts/<this hook's script>pre-push-gate.sh for pre-push, commit-msg-guard.sh for commit-msgMarker balance is a property of two comment lines. A pair whose body was emptied, commented out, or points at the other hook's script is perfectly balanced and runs no gate — and every clause above would still pass it

The third clause is what "carries the sd0x stanza" always meant; stating it as marker balance alone was a round-79 narrowing that would have let doctor report installed × Active over a hook that executes nothing. Presence and intactness are different questions: doctor's "sd0x stanza present" probe greps the opening marker and answers the first, which is all a report needs.

The stanza is prepended even when the Husky hook is created by this skill — writing it first is what makes "prepended" a property of the file rather than of the order two installs happened to run in.

The Husky commit-msg stanza

commit-msg is the hook a flagless init installs (§ Modes), so under Husky it needs its own written-out path — and it is not the push-gate stanza with a name changed. What git hands the two hooks is different, and the whole capture-and-hand-back apparatus above exists for a property commit-msg does not have:

pre-pushcommit-msg
How the input arrivesthe ref list on stdin, readable oncethe message file's path, as $1
So the stanza mustcapture the stream, pass the copy, hand fd 0 backpass "$@" through and nothing else
Leftover statea 0600 temp file to unlinknone

Everything else carries over unchanged, because the threat does: this hook runs with the committer's whole environment, and an exported BASH_FUNC_name%% answers test, bash, exec and exit alike. So the same three immune constructs, for the same reasons the table under the push-gate stanza gives — case, ${x:?}, and an absolute /usr/bin/env — with everything real inside /bin/bash -p.

# >>> sd0x-dev-flow commit-msg guard >>>
# Executed, never sourced — and the reason is NOT the push-gate stanza's, because the guard behaves
# differently and the difference is what matters. The guard re-execs itself privileged through
# `${BASH_SOURCE[0]:-$0}`, which under `.` resolves to the guard, so the re-exec names the right
# file — and then `exec` REPLACES the sourcing shell with it. Measured 2026-08-22: a parent hook
# that sources this guard runs its own first line, hands the process over, and never reaches a
# single line after the `.` — and this stanza is PREPENDED, so everything the project put in its
# own commit-msg hook is what silently stops running. `-u SD0X_PRIV_REEXEC` for the same reason as
# under the push-gate stanza: the guard
# establishes privileged mode through that marker, so an inherited one would make it skip its own
# re-exec. `ALLOW_AI_COAUTHOR` is deliberately NOT unset — it is the narrow opt-in the attribution
# anchor defines, and the guard, not this stanza, is what decides what it may do.
/usr/bin/env -u SHELLOPTS -u BASHOPTS -u BASH_ENV -u ENV -u SD0X_PRIV_REEXEC \
  /bin/bash -p -c 'test -r "$1" || exit 0; g=$1; shift; exec "$BASH" -p -- "$g" "$@"' \
  sd0x-commit-msg ./.claude/scripts/commit-msg-guard.sh "$@"
__sd0x_cm_rc=$?
case "$__sd0x_cm_rc" in
  0) ;;
  *) __sd0x_cm_abort=''
     : "${__sd0x_cm_abort:?sd0x commit-msg guard: refused this commit message}" ;;
esac
# <<< sd0x-dev-flow commit-msg guard <<<

test -r "$1" || exit 0 carries the same meaning as in the push stanza and no more: an uninstalled or half-removed guard leaves the commit alone, so the project's hook behaves as it did before. It is not a substitute for the Active predicate below — a misinstalled guard is what that check is for.

Writing a hook file is not installing it. Verify that git will actually run it before recording installed. Priority 4 only prints the core.hooksPath command; until someone runs it, git still looks in .git/hooks/ and neither the commit-msg guard nor an opted-in push gate ever fires. Recording that as installed would make doctor green over a repository with no active guard at all — the exact failure both hooks exist to prevent. So every mode ends with the same check, and it is a check on git's own answer, not on the file existing:

# What does git itself resolve this hook to?
resolved=$(git rev-parse --git-path "hooks/${hook}")

The comparison depends on the mode, and -ef against $written_path is only correct where git runs the written file directly. Husky is the case that breaks it: the normal modern shape is core.hooksPath=.husky/_, so git runs .husky/_/<hook> — a Husky-owned shim — which sources .husky/<hook>, the container the sd0x stanza was written into. Those are two different files, so an -ef test reports a fully active hook chain as inactive.

The mode numbers below are the priority numbers above — the same four modes, not a second scheme. Mode 1 is Husky, so it takes the Husky row and never the direct-file row; an earlier version of this table grouped "1–3" as direct-file and carried a separate Husky row, leaving a literal reader with two rows claiming mode 1.

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
188
Forks
24
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
codex-setup
Source
github.com/sd0xdev/sd0x-harness