CLAUDE.md@cli · git:20260922.7f58255 · 2026-09-22 · sha256 d84a7617765aed62

CLAUDE.md@cli git:20260922.7f58255A

Immutable. This exact content never changes and is served at /api/v1/blob/d84a7617765aed62.

# Working in `cli/`

**This directory is not the `eklavya` binary.** That is `mcp/src/cli.ts`,
published as `dist/cli.js` and the package's only `bin`. `cli/` holds exactly one
file: `eklavya-gate`, the POSIX commit-gate script. The name invites the
confusion, so check which one a request means before you edit anything —
`eklavya config`, `eklavya doctor`, `eklavya dashboard` and `eklavya install` all
live in `mcp/src/cli.ts`.

It is a shell script and not Node on purpose: it runs as a git `pre-commit` hook
on every commit, and a git hook must not pay Node's startup cost. Keep it POSIX
`sh` — the installed hook execs it with `#!/bin/sh`, and `mcp/test/gate.test.ts`
drives it through `/bin/sh`.

## It is opt-in, twice over

Nothing in `eklavya install` puts this on a repo. `scripts/install-git-hook.sh`
does, as a separate step the user runs per repo; it writes `.git/hooks/pre-commit`
between the `# >>> eklavya gate >>>` markers, and if a `pre-commit` already
existed it moves it to `pre-commit.local` and chains it first. `--uninstall`
restores it.

Then the script itself only acts on a project whose config sets `quiz.enforced`
(or the retired `"mode": "enforced"`, which it still reads). That config is at
`~/.eklavya/projects/<slug>/config.json` — the script rebuilds the slug itself,
in shell, from `git rev-parse --show-toplevel` folded to the main checkout via
`--git-common-dir`, because a worktree shares its parent's settings. Note that it
reads enforcement from **only** the project file — not `~/.eklavya/config.json` —
so a globally set gate does not hold a bare terminal. The global file is read for
one thing: a `quiz.enabled: false` (or `mode: off`) there, not overridden by the
project, releases the gate, exactly as `coerce()` does — otherwise a terminal
commit is held by a gate no quiz will ever run to clear. The slug path is built
with `pwd -P` so a checkout reached through a symlink still finds its file.

It reads `<repo>/.eklavya.json` as a fallback and does **not** migrate it. The
node half moves that file out automatically; a git pre-commit hook is the wrong
place to start rewriting somebody's working tree, so it reads the old location
and lets the next ordinary session do the move. Any doc claiming a terminal commit is gated has to attach the installer
step and the repo config; `web/src/content/docs/docs/commit-gate.mdx` and
`skills/setup/SKILL.md` are where that lives.

## It fails open, always

A learning tool that bricks commits gets uninstalled, and an unpassable gate
teaches nothing. Every one of these exits 0:

- not inside a git repo, or `git rev-parse` fails;
- no config for this project, at either the current or the legacy location;
- `jq` not on PATH (warns on stderr) — **or** `sqlite3` not on PATH (warns too).
  It needs both, not just `jq`;
- the config does not ask for enforcement — `quiz.enforced` false or absent with
  no `mode: enforced` behind it, or `quiz.enabled` false — in the project file,
  or in the global one when the project does not say;
- the database file does not exist;
- the `sqlite3` query errors, or returns no gate row for this repo.

Exit 1 happens on exactly one condition: a gate row for this repo whose `passed`
is not `1`. Adding a new failure mode means adding a new `exit 0` path.

## It reads the database directly

There is no server in a git hook, so the script talks to `knowledge.db` in raw
SQL and duplicates things it cannot import. Keep it in step with:

- the `gates` table shape — it selects `passed, required, answered` and picks the
  repo's most recent row by `updated_at`. `syncGate` in `mcp/src/store.ts` is the
  only writer; a renamed or added column lands here in the same commit.
- `DEFAULT_CONFIG.quiz` in `mcp/src/config.ts` — the script defaults a config
  with neither `quiz` nor `mode` to unenforced.
- `projectSlug` in `mcp/src/paths.ts` — the script rebuilds it with
  `tr '/\\:' '-'`, and `test/gate.test.ts` runs both parsers over one set of
  configs so the two cannot drift apart silently.
- the `mode` → `quiz` alias in `normalizeLegacyKeys`, and the rule that
  `quiz.enabled: false` forces `enforced` off. Both are duplicated here in jq,
  spelled out with `if`/`elif` rather than `//`: jq's `//` is an alternative
  operator, so `.quiz.enforced // (.mode == "enforced")` reads an explicit
  `false` as unset and falls back to a stale `mode` in the same file — a gate
  that keeps holding commits after somebody switched it off.
  `test/gate.test.ts` runs both parsers over the same five configs.

The pass/fail arithmetic itself (`PASSING_GRADE`, `pass_threshold`,
`ceil(required * pass_threshold)`) is **not** duplicated here — the script trusts
the persisted `passed` column. Keep it that way: a change to the gate's rule
belongs in `syncGate` alone. What is duplicated is the lookup key, and it differs
from the in-session hook on purpose: `mcp/src/hooks/pre-tool-gate.ts` looks the
gate up by `session_id`, this script by `repo`, because a terminal commit has no
session.

## Tests

`mcp/test/gate.test.ts` runs the real script and the real installer against a
temp repo, and ends with an actual `git commit`. From `mcp/`:

```sh
npm test -- gate
```

`pretest` builds `dist/`, which `pre-tool-gate.js` in the same file needs.