This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
A CLI that creates and manages private mirrors of public GitHub repos for vendor/contractor workflows. Three-repository pattern:
- Private mirror (
origin,owner/project-private) — where the team works; invisible to the client. - Public fork (
public,owner/project) — staging area for upstream contributions. - Upstream (
upstream,original/project) — read-only source; push is disabled (git remote set-url --push upstream DISABLE).
--own-upstream mode collapses this to origin + upstream only (use when you own upstream). The active layout is recorded as mode: 'standard' | 'no-public' in config.
User-facing names and on-disk names differ on purpose. The CLI says venfork keep and --own-upstream; the config keeps the field preserve, the value mode: 'no-public' and the doctor check id preserve, so every existing mirror reads unchanged and there is no migration. Code that talks to the user uses the new names; code that reads or writes config uses the old ones. The old spellings venfork preserve and --no-public exit 2 and name the new ones (RENAMED in src/index.ts, renamedFlag in src/shared/args.ts).
Runtime is Bun (primary); Node.js >=22 is a supported target (engines.node; CI runs Node 22 and 24). bun run smoke:node fails when a runtime dependency needs a newer Node than engines.node allows.
bun install # deps
bun run dev <cmd> ... # run CLI from src/ (e.g. bun run dev setup --help)
bun run test # unit, then integration (two bun processes)
bun run test:unit # tests/unit: string-mocked execa, fast
bun run test:integration # tests/integration: real git against local bare repos
bun test ./tests/unit/utils.test.ts # single file
bun test -t "parses owner" ./tests/unit # single test by name
bun run test:watch # watch
bun run test:coverage # coverage
bun run verify # the only command that means "done": format:check, lint, lint:invariants, typecheck, tests, build, node smoke (non-mutating)
bun run check # biome check + autofix (lint + format); mutates files, so it is not a gate
bun run lint # biome lint only
bun run lint:invariants # scripts/lint-invariants.ts repo invariant checks
bun run build # bundle to dist/ (node target)
bun run compile # standalone binary -> dist/venfork
bun link # symlink global `venfork` for manual testingbun run build inlines every runtime dependency (@clack/prompts, execa), so dist/index.js imports only Node builtins and the published package has no dependencies: npm install -g venfork installs nothing else. Add runtime libraries to devDependencies. bun run smoke:node (part of verify and the release job) fails when the bundle imports a non-builtin, then packs the package, installs the tarball offline with --omit=dev into an empty project, checks that only venfork lands in node_modules, and runs its bin under Node.
Unit tests mock execa with mock.module, which leaks across files in one bun process, so integration tests run in a separate invocation. bunfig.toml sets the test root to tests/unit, so a bare bun test runs only unit tests; other suites need an explicit ./ path (bun test ./tests/integration). Integration tests build a throwaway upstream/origin/public layout of local bare repos with tests/harness/mirror-fixture.ts and run commands against it with only @clack/prompts mocked.
E2E tests hit real GitHub and are gated behind env flags (slow, opt-in):
bun run test:e2e # VENFORK_E2E=1
bun run test:e2e:dispatch # + real workflow_dispatchNever run the real-GitHub e2e unless the user asks. It needs the test org (memcard-dev, or VENFORK_E2E_ORG) and a token with repo create and delete rights (delete_repo scope). Run it with env -u GITHUB_TOKEN -u GH_TOKEN so gh uses the keyring token (gh prefers GH_TOKEN, then GITHUB_TOKEN, then the keyring). To push with a specific token instead, set VENFORK_E2E_PAT; it overrides gh auth token as the e2e push token. Each run must leave no venfork-e2e-* repos behind: check with gh repo list "${VENFORK_E2E_ORG:-memcard-dev}" --limit 1000 | grep venfork-e2e- (and the same for ${VENFORK_E2E_UPSTREAM_OWNER:-cabljac}). The default proof for a change is bun run verify plus a harness drive (.agents/skills/verify-venfork/). A PR that did not rerun e2e must state "Not verified: e2e tiers".
CLI entry src/index.ts parses argv[0] as the command and dispatches via a switch. Each command has a dedicated arg parser (src/<command>-args.ts) returning a typed options object, then calls the matching *Command function in src/commands/<command>.ts (re-exported through the src/commands.ts barrel). To add/modify a command: touch the arg parser, the command impl, the src/index.ts switch, src/dispatch.ts (gh auth, repo check), the help entry in src/commands/help.ts (and showHelp if it belongs on the one screen), its docs/reference/commands.md section and its verify-venfork feature file.
src/commands/ holds one file per command. Helpers shared by several commands live in src/shared/: net.ts (network-op safety), managed-commit.ts (managed-commit detection), mirror-commit.ts (building the managed commit), divergence.ts, redaction.ts, worktree.ts (temporary detached worktrees), prompt.ts (every interactive prompt), ui.ts (all human output, on stderr), json-output.ts (the one --json envelope: a command returns its result, src/index.ts writes it, error.code comes from VenforkError.code, and a failed git/gh subprocess maps to git/github), interrupt.ts (a command that handles SIGINT/SIGTERM itself claims them, so the --json signal envelope waits for its rejection), constants.ts. The rest are focused modules:
config.ts— the source of truth for persisted state. All cross-run state lives inVenforkConfig(.venfork/config.json) on an orphanvenfork-configbranch in the private mirror — never on a working branch. It tracksupstreamUrl,publicForkUrl,mode,schedule,scheduleAuth(top level;'app'or absent for token auth),approval(top level;'reviewed','two-person'or absent for off, set byvenfork stage approval), workflow allow/block lists, thepreserveallowlist, and link maps (shippedBranches,pulledPrs,shippedIssues,pulledIssues). Mutate viaupdateVenforkConfigwith aVenforkConfigPatch(shallow-merge;nulldeletes an entry or clears a field). Read withfetchVenforkConfig(clones only the config branch to a temp dir) orreadVenforkConfigFromRepo.git.ts— thin git/gh wrappers (checkGhAuth,getRemotes,getDefaultBranch,ghRepoExists,ghRepoIsForkOf, …).utils.ts— URL/shorthand parsing (parseOwner,parseRepoName,parseRepoPath,normalizeGitHubRepoInput); shorthandowner/repois treated asgit@github.com:owner/repo.git.workflow.ts— generates the deterministic GitHub Actions sync YAML (.github/workflows/venfork-sync.yml).errors.ts— typed errors (VenforkError+ subclasses);index.tsprints.messageand exits 1, or 2 forUsageError(malformed input) andMissingInputError(no prompt possible). Commands never callprocess.exit: one that already printed its outcome throwsCommandExitError(code), whichindex.tsturns into that exit code without printing more. A command's catch rethrowsisEntryReportederrors unchanged.
When scheduled sync, a preserve allowlist or a workflow allow/block list is active, the mirror's default branch is kept at upstream/<default> plus at most one venfork-managed commit (subject chore: venfork-managed mirror commit, trailer Venfork-Managed: 1). That commit carries the sync workflow YAML (filtered by enabledWorkflows/disabledWorkflows) and any preserved mirror-only files. When that content adds nothing to the upstream tree (for example every preserved file now exists upstream), there is no managed commit and the mirror tip equals the upstream tip (+0).
Determinism rule: the managed commit's author and committer are the venfork bot and both dates are the committer date of the upstream tip, and it is built with git commit-tree (no hooks, no signing). Its SHA is therefore a function of the upstream tip and the tree. A sync with no upstream or config change rebuilds the same SHA, sees origin already points at it, and pushes nothing. When a push is needed it is a single --force-with-lease=refs/heads/<default>:<previous tip> push; public is skipped when it already equals upstream.
isManagedCommit checks the trailer first, then the current subject, the legacy subjects, and a workflow-path heuristic, so upgrades don't misclassify history as user divergence. Sync aborts on divergent commits (SyncDivergenceError) to prevent data loss. When editing sync logic, preserve this invariant: it is what lets sync stay idempotent and divergence-detectable.
venfork stage (unless --no-pr) and venfork stage issue promote internal content to upstream. Before doing so, stripInternalBlocks removes <!-- venfork:internal -->…<!-- /venfork:internal --> regions from PR/issue bodies and titles. Markers match case-insensitively (<!-- VENFORK: internal (note) --> works); an unmatched close marker or any leftover HTML comment mentioning venfork throws RedactionError instead of publishing. Anything that flows from mirror → public/upstream must go through this redaction. Stage also refuses (StageLeakError) to push a head whose diff against upstream/<default> adds the managed workflow or a preserved path upstream does not have.
With approval set (venfork stage approval), stage reads the branch's open mirror PR through gh api (src/shared/approval.ts): the latest approving or changes-requested review per reviewer, candidate approvers' permission, and every check run and status on the head. It pins the rebuild to the approved head with source.sha. A lowered level (compared with the clone memory in the git common dir, src/shared/approval-guard.ts) and a --force bypass each need a person at a TTY; --yes never answers those confirms. The bypass is recorded only as a comment on the mirror PR. venfork-config stores only the level. No approval result or bypass record goes upstream, to the public fork or into venfork-config. It is a guardrail, not enforcement: never describe it as protection.
venfork doctor (src/commands/doctor.ts) returns { id, ok: true | false | 'skipped', detail, fix? } per check and exits 1 when any check is false. Git checks are covered by integration tests; the network checks (transport, token, last-run, cron-age) are string-mocked in tests/unit/doctor.test.ts. token, last-run and cron-age degrade to skipped without gh auth or a GitHub origin; transport skips only for a non-GitHub origin.
stdout carries only results (--version, help that was asked for, and the one --json envelope). Everything else goes to stderr through the src/shared/ui.ts facade (log, note, spinner, intro, outro, cancel), and prompts render on stderr too. The facade delegates to clack when stderr is a TTY, NO_COLOR is unset or empty and TERM is not dumb, and prints plain prefix message lines otherwise. Subprocess stdout from netExec is routed to stderr. The test preload pins process.stderr.isTTY to true so ui output reaches the mocked @clack/prompts; assert clack calls with ON_STDERR from tests/harness/prompts.ts.
Every heavy git/gh network op goes through netExec/runNetOp in src/shared/net.ts: no stdin (credential/host-key prompts fail fast instead of hanging), a hard timeout (VENFORK_GIT_TIMEOUT, default 600s), and BatchMode=yes. Use these helpers for new network calls rather than raw $ — a misconfigured credential should error, not block on an invisible prompt.
netExec also puts -c credential.https://github.com.helper= -c credential.https://github.com.helper=!gh auth git-credential right after git on every git command it runs, so an HTTPS github.com remote authenticates with gh (GH_TOKEN or gh auth login) without gh auth setup-git. The empty entry resets the helper list for https://github.com, so gh is the only helper there: the user's own helper (osxkeychain, store) is never asked and never stores a gh token, which would go stale. The helper is scoped to https://github.com, so local-path remotes and other hosts never reach it, and a missing or unauthenticated gh fails closed (terminal prompts disabled). Run non-network git through plain $ so it does not get the helper. In the generated workflow, actions/checkout's http.https://github.com/.extraheader sends the push token on every request from the checkout, so git does not ask a helper for those fetches and pushes.
.agents/skills/verify-venfork/ is the per-command behaviour reference: one feature file per src/commands/<name>.ts, plus leak invariants and multi-command journeys.
bun scripts/verify-fixture.ts [--shell] is the harness lever: it builds the local upstream/origin/public fixture and prints the paths and env to drive the built CLI against it.
VENFORK_ORG— default org for created repos (--orgflag overrides; no org → prompts before using personal account).VENFORK_GIT_TIMEOUT— ms cap per network op (default 600000).VENFORK_NONINTERACTIVE=1- never prompt, like--no-input; it does not confirm (only--yesdoes). venfork also never prompts without a TTY on stdin and stderr or withCIset (notfalse/0); a prompt it cannot show exits 2 (MissingInputError) naming the flag. Every command accepts--no-input(a no-op where nothing prompts).VENFORK_PUSH_TOKEN— token used by the generated sync workflow for pushes.VENFORK_APP_CLIENT_ID/VENFORK_APP_PRIVATE_KEY- repo secrets the generated workflow mints its push token from whenscheduleAuthisapp(schedule set --app).VENFORK_E2E_APP_CLIENT_ID/VENFORK_E2E_APP_PRIVATE_KEY- GitHub App credentials that enable the App variant of the e2e dispatch tier.VENFORK_SEED_CHUNK/VENFORK_SEED_RETRY_MS— commit batch size and retry delay for the initial mirror seed push.VENFORK_INSTALL_SPEC— repository variable read by the generated workflow to override the pinnednpm install -gspec.VENFORK_ALLOW_SELF_REFERENCE=1— stopsstagerefusing the bare wordvenforkin commit messages, PR titles and bodies (mirror URL, owner and repo terms stay refused).
- TypeScript strict mode; Biome for lint+format (
bun run check). Releases via release-please (Conventional Commits drive version bumps + CHANGELOG). - Tests use Bun's runner with
test()(notit()). - Review and PR shape: use
.agents/skills/review-venfork/for adversarial review and.github/pull_request_template.mdfor PR bodies. - Known traps live in
docs/agents/gotchas.md; append there after a correction. - Log papercuts you hit in this repo's tooling, docs, tests or conventions with
bunx frog log(runbunx frog listfirst); commit the entry with the change that hit it. Rules:.agents/friction-log/README.md. Do not install Frog globally. - User docs:
README.mdis the front page (at most 200 lines: what venfork is, install, quick start, the command table, links). Detail lives indocs/reference/*.md(commands, scheduled-sync, json, headless, workflow, migration, development).tests/unit/docs-drift.test.tsreads both: command sections, flags and env vars may live in any of them, every reference page must be linked from the README, and every relative link must resolve. gh repo cloneis used for fetching, so SSH-vs-HTTPS transport follows the user'sgh config get git_protocol.- Invariants (remote refs, git network ops, dynamic git subcommands and
gh api/gh repo clone|viewvia net.ts, import boundary,gh pr|issue create|editonly insrc/shared/upstream-publish.ts, process.exit, clack prompts only insrc/shared/prompt.ts,@clack/promptsimported only insrc/shared/ui.tsandsrc/shared/prompt.ts, no em dash (U+2014) in src/, tests/, docs/ or README.md) are enforced by scripts/lint-invariants.ts.