# Designing a `@nurix/*` distributable CLI

Load when: building or changing a distributable `@nurix/*` command-line tool.

Conventions for building a distributable Nurix command-line tool, distilled from the two we ship: **`@nurix/etna`** (`packages/cli/` in the etna repo — the harness installer, plain ESM JS) and **`@nurix/nustack`** (`~/dev/apollo/packages/cli/` — the service-bootstrap CLI, TypeScript → `dist/`). When you build or change a `@nurix` CLI, follow these. Where the two diverge, both variants are noted as valid.

## 1. Packaging

The base `package.json` fields — scope, `publishConfig.access: "restricted"`, `files`, build hooks — are [`publishing.md`](publishing.md) §2. CLI-specific on top:

- **ESM only** — `"type": "module"`.
- **`bin` maps the command to its entry** — `{ "etna": "bin/etna.js" }` / `{ "nustack": "dist/index.js" }`. The entry has a `#!/usr/bin/env node` shebang.
- **`files` ships only the runnable artifact** — `["bin/", "data/", "README.md"]` (etna) or `["dist"]` (nustack). Never ship `src/`, tests, or the repo.
- **`engines.node` ≥ 22** for new CLIs (matches the workspace Node baseline).
- **One version source** — read the running version from `package.json` at startup; don't hardcode it elsewhere.
- **Minimal deps.** Both ship with essentially one runtime dep (`@clack/prompts`); nustack adds `commander` + `ajv`. Keep the dependency surface tiny — it's installed on every `npx` run.

## 2. Distribution: `npx`-first, install into the project, never globally

- The canonical entry is **`npx @nurix/<tool>`** — no global install step, no marketplace, no plugin-enablement dance. The user runs `npx`, the tool does its job in the current directory.
- **The CLI writes ONLY inside the folder it was invoked in.** An installer run as `npx @nurix/etna` in a project must touch only that project tree — never `$HOME`, never `~/.claude`, never any shared/user-level location. Reaching outside the target folder is surprising, clobber-prone, and wrong. (This is a hard-won rule: an earlier etna build installed agents to `~/.claude/agents/` and it was reverted precisely for this reason.)
- **Idempotent refresh.** Re-running the CLI re-pulls the latest and overwrites the files it owns (force), while leaving anything the user authored untouched. "Re-run anytime to refresh" must always be safe.

## 3. Always run the latest — self-update by re-exec

**The problem:** `npx` caches aggressively. A bare `npx @nurix/<tool>` can keep running a *stale* unpacked copy long after a newer version is published (npm's packument cache resolves an old `latest`, and npx reuses the matching install). Users shouldn't have to remember `@latest`.

**The mechanism** (etna's `selfUpdateOrReexec()`, run before anything else): on startup, ask the registry for the latest version; if this copy is behind, re-exec the latest and hand off.

```js
function selfUpdateOrReexec() {
    if (process.env.TOOL_SELFUPDATED || process.env.TOOL_NO_SELFUPDATE) return;
    const here = readJson(join(pkgRoot, 'package.json')).version;
    if (!here) return;
    const view = spawnSync('npm', ['view', '@nurix/<tool>', 'version', '--prefer-online'], { encoding: 'utf8' });
    const latest = view.status === 0 ? (view.stdout || '').trim() : null;
    if (!latest || latest === here) return;                 // current, offline, or unauthed → run this copy
    const child = spawnSync('npx', ['-y', '--prefer-online', `@nurix/<tool>@${latest}`, ...process.argv.slice(2)],
        { stdio: 'inherit', env: { ...process.env, TOOL_SELFUPDATED: '1' } });
    if (!child.error) process.exit(child.status ?? 0);      // re-exec ran → hand off complete
    // npx unavailable (child.error) → fall through and run this (older) copy.
}
```

Required properties:

- **`--prefer-online`** on both the `npm view` and the `npx` re-exec — defeats the metadata cache so npx fetches the fresh tarball.
- **Loop guard** — set `<TOOL>_SELFUPDATED=1` in the child's env so the re-exec'd process doesn't try to update again.
- **Opt-out** — `<TOOL>_NO_SELFUPDATE=1` skips the whole thing (CI, offline, or running an intentionally pinned/local-dev build). This is also how you test a local build.
- **Degrade gracefully** — *any* failure (offline, unauthed, `npm`/`npx` missing, timeout) falls through to running the current copy. The update is a convenience, never a gate.
- **Reuse existing npm auth** — the `npm view` rides the caller's npm credentials (the same auth that installed the restricted package); never prompt for login.

## 4. Editing the user's files: own a delimited block, idempotently

When the CLI must modify a file it doesn't own (`.env`, `settings.json`, `.gitignore`), it **owns only a delimited region** and rewrites that region in place — never the whole file.

- nustack wraps its `.env` writes in a managed block:
  `# >>> nustack:managed — do not edit by hand; the nustack CLI rewrites this block >>>` … `# <<< nustack:managed <<<`. Reads/merges/removes happen *inside* the markers; everything outside belongs to the user.
- etna merges its hooks into `.claude/settings.json` and migrates/retires only its own prior entries.
- Rewrites are **idempotent** (re-running converges to the same block) and **reversible** (logout/uninstall removes exactly the managed region).
- If the block holds secrets, ensure `.gitignore` covers the file so they're never committed.

## 5. Interactive when it can, flag-driven when it can't

- Use **`@clack/prompts`** for the interactive path (TTY): intro/outro, spinners, notes.
- When **not a TTY**, require explicit flags and fail loudly with the correct syntax rather than guessing — e.g. etna needs `--name=<harness>` outside a TTY, and a removed/renamed flag (the dropped positional form in 0.18.0) errors with the new syntax instead of silently misfiring.
- A bare invocation in a TTY may present a menu; the same invocation headless must error, not hang.

## 6. Detect the package manager from the lockfile

When the CLI installs packages or runs scripts on the user's behalf, detect the manager: `pnpm-lock.yaml` → pnpm, `yarn.lock` → yarn, else npm. Never hardcode one. (nustack's `detectPackageManager()`.)

## 7. Publishing is CI-owned

Publish from CI on push to `dev`/`main`, path-filtered to the CLI package — never `npm publish` by hand for a routine change. The pipeline mechanics — workflow template, tags, skip directives, `registry-url`, monorepo `working-directory` — are [`publishing.md`](publishing.md) §3–5. CLI-specific on top:

- **Two valid version models:**
  - **Fingerprint auto-bump (etna):** the build stamps a content hash (`fingerprint`) of the shipped payload; CI bumps the **patch** automatically when the fingerprint changed, publishes, and tags. Never hand-bump for a content change — let the fingerprint drive it; hand-edit the version only for a deliberate minor/major.
  - **Manual bump input (nustack):** a `workflow_dispatch` `bump` input (`patch`/`minor`/`major`) drives `npm version`.
- **`npm version --no-git-tag-version`** (file-only), then commit + tag separately — `npm version` skips the git step when run in a subdirectory of the repo root.

## 8. Robustness defaults

- **Never hard-block on an advisory/network check.** Version currency, registry lookups, telemetry — all degrade to a one-line notice and continue.
- **Restricted-scope auth is ambient** — the read token in `~/.npmrc` (or `$NODE_AUTH_TOKEN` in CI) authenticates installs and `npm view`. Just attempt the operation; never prompt for `npm login` or reach for a substitute package.
- **Keep the hot path fast** — everything runs on every `npx`, so avoid heavy startup work beyond the self-update check.
