Everything a developer types at the terminal: procni init, procni doctor, procni dev, procni analyze, and procni draft <opportunityId>. This package owns argument parsing and the thin per-command orchestration; it delegates all real work — config, database, git, the AI pipeline — to @procni/core and @procni/ai.
Why Commander, not a hand-rolled process.argv parser or a heavier framework like oclif. Procni has a handful of flat commands with no nested subcommand trees or plugin system — Commander gives --help, argument parsing, and command registration in a few declarative lines without oclif's project-generator conventions and class-based command structure, which would be more machinery than five commands need.
Why every command is split into a pure function plus a thin action() wrapper. Look at commands/init.ts: runInit(cwd) takes a plain string and returns a plain InitResult object — it never calls console.log. index.ts is the only place that turns that result into terminal output. This split is what makes init.test.ts possible without capturing stdout or mocking console — the test calls runInit directly and asserts on real return values and real filesystem side effects. If printing were inline in runInit, testing it would mean intercepting console output, which is a worse test for the same coverage.
Why findGitRoot is imported from @procni/core now, not defined here. Early on (Phase 0), before any real git module existed, this package had its own tiny one-off copy — building a full git wrapper for a single shell call would have been scope creep. Phase 1 added packages/core/src/git for real (log/diff parsing — see its README), so the CLI's copy was deleted and every command now imports the real thing from @procni/core. This is the follow-through on that documented deferral, not a new decision.
Why bin points at ./bin/procni.js — a shim, not compiled output — and why that's not the final distribution mechanism. There's no scripts/build.ts bundler yet (that's real, deferred work — see the project plan), so bin can't point at compiled JavaScript the way a published npm package normally would. bin/procni.js is a small stand-in: it uses tsx's own documented tsImport API to import src/index.ts directly, so the package can be pnpm link --global'd and run as a real procni command today, from any directory, without a build step. The first version of this shim used Node's lower-level node:module register() hook directly and a pathToFileURL(import.meta.url) call that looked reasonable but double-wrapped an already-a-URL value — it happened to work when tested from the workspace root and broke silently everywhere else, which is exactly the failure mode that only shows up once you test a global link from an unrelated directory rather than re-running pnpm --filter procni exec tsx src/index.ts from the same place every time. Both mistakes are left in the file's comments as the reasoning trail. When scripts/build.ts exists, bin should point at real compiled/bundled output and this shim should go away — it's a bridge, not a design decision to defend later.
Why running the CLI during development isn't pnpm --filter procni exec tsx src/index.ts from the repo root. pnpm --filter <pkg> exec changes the working directory to that package's folder before running the command — so a path like apps/cli/src/index.ts (root-relative) resolves against apps/cli/apps/cli/src/index.ts and fails, and even the corrected relative path (src/index.ts) changes process.cwd() away from wherever you actually meant to run procni against, which breaks both git-root detection and .env loading (both resolve against process.cwd(), on purpose — see lib/loadEnvFile.ts and packages/core/src/git/repo.ts). The root package.json's "procni" script (pnpm procni <command>, run from the repo root) exists specifically to avoid this — it invokes tsx apps/cli/src/index.ts without changing directories first, so process.cwd() stays wherever you ran pnpm procni from.
Why procni dev's webDistDir points three directories up from its own compiled location, not at anything inside the project being analyzed. It's easy to misread ../../../../web/dist in commands/dev.ts as project-relative — it isn't. web/dist is Procni's own built dashboard, shipped alongside the CLI; the path is relative to this file's location so it keeps working regardless of which directory a developer happens to run procni dev from. The developer's project (repoRoot, found via findGitRoot) is a completely separate path, used only for .procni/ state.
src/lib/appendGitignore.ts— idempotently ensures.gitignorecovers.procni/'s generated files without ever touching a developer's existing lines. Read the diffing logic here as the reference pattern for "safely append to a file a user might have already customized," which recurs anywhere Procni touches files it doesn't fully own.src/commands/init.ts—runInit: find the repo → read-or-create the project config (@procni/core'sreadProjectConfig/writeProjectConfig) → fix up.gitignore→ open the database once (which is what actually runs migrations, via@procni/core'sopenDb) → register the project in the global registry. Every step no-ops correctly if it already happened, so runningprocni inittwice is harmless — see the "idempotent" test case.src/commands/doctor.ts—runDoctorreturns a plain array of{name, ok, detail}checks;formatDoctorReportturns that into the ✓/✗ terminal output. Same split-for-testability pattern asinit.ts.src/commands/dev.ts—runDev: find the repo, hand off to@procni/server'sstartDevServer(seeapps/serverfor what that actually does), print the URL, open a browser (skippable with--no-open), and installSIGINT/SIGTERMhandlers so Ctrl-C releases the PID lock and closes the database instead of leaving them dangling. Returns the running handle rather than blocking forever internally, which is what letsdev.test.tsstart a real server, hit it, and callhandle.stop()itself instead of having to send the process a real signal.src/commands/analyze.ts—runAnalyzeCommand: resolve the project and a Gemini key via the two shared helpers below, then call@procni/core'srunAnalysisdirectly and synchronously — no job queue, unlike the same pipeline run triggered from the web UI (seepackages/core/src/pipeline/README.mdfor whyrunAnalysishas two callers). Blocking is fine here; a CLI command is expected to run to completion. Prints each opportunity's id so a developer can pass it straight toprocni draft <id>.src/commands/draft.ts—runDraftCommand: the identical shape one layer down, callinggenerateDraftinstead ofrunAnalysis.procni draft <opportunityId>prints the finished draft straight to the terminal.src/lib/resolveProject.ts/src/lib/resolveProvider.ts— factored out oncedraft.tsneeded the exact same "find the initialized project" and "resolve a configured AI provider or fail clearly" logicanalyze.tsalready had. Neither existed until there were two real callers — see the project's no-slop rule on this same principle, applied here to CLI commands rather than pipeline code.resolveProvider.tsused to beresolveGeminiApiKey.ts, hardcoded to one provider; once@procni/aigrew Anthropic/OpenAI/OpenRouter/Ollama adapters, it was rewritten around@procni/ai'sresolveConfiguredProvidersoprocni analyze/procni draftwork with whichever BYOK key (orPROCNI_OLLAMA_BASE_URL) a developer actually has configured, not just Gemini.src/lib/loadEnvFile.ts—loadDotEnvIfPresent, called once at the very top ofindex.tsbefore any command runs. Loads a.envfile from the current directory via Node's built-inprocess.loadEnvFile— nodotenvpackage needed, since Node has shipped this natively since well before this project's Node 22 minimum. This is howPROCNI_GEMINI_API_KEY(seepackages/ai/README.md's key-resolution precedence) can live in a.envfile instead of needing to be exported in your shell every session.src/index.ts— the only file that touchesconsole.log/process.exitCode. Registers commands with Commander and turnsProcniErrors into a one-line message instead of a stack trace.
pnpm --filter procni testEvery test here creates a real temporary directory and, where relevant, runs real git init in it (init.test.ts, doctor.test.ts, dev.test.ts, analyze.test.ts, draft.test.ts) rather than mocking child_process — findGitRoot itself is tested where it's defined now, in packages/core/src/git. Most of these also point $PROCNI_HOME at a temp directory for the duration of each test so the suite never touches your actual ~/.procni. analyze.test.ts/draft.test.ts mock @procni/ai's resolveConfiguredProvider (driven by whether PROCNI_GEMINI_API_KEY is set, swapping in a FakeProvider when it is) rather than a real network call — the "no provider configured" NO_PROVIDER failure path still runs through the real resolveProviderOrThrow, just against a mocked resolution result rather than a real keychain lookup (that lookup logic itself is fully covered by packages/ai's own registry.test.ts).
To try it by hand instead of through the test suite, either run it as a real global command (see below) or, from the repo root, without changing directories:
pnpm procni init
pnpm procni doctorpnpm link --global (run from apps/cli/) symlinks this package into pnpm's global bin directory, making procni a real command on your PATH from any directory — no compiled build needed, since bin/procni.js is the tsImport-based shim described above. If pnpm link --global fails with "Unable to find the global bin directory," run pnpm setup once first (it adds PNPM_HOME to your shell profile) and open a new shell, or export PNPM_HOME="$(pnpm config get global-bin-dir 2>/dev/null || echo "$HOME/Library/pnpm")" and add it to PATH for the current shell. To undo: pnpm uninstall -g procni (or pnpm unlink --global from apps/cli/).
Depends on @procni/core (config, db, git, runAnalysis, generateDraft), @procni/shared (ProcniError), @procni/server (startDevServer, for the dev command), and @procni/ai (resolveConfiguredProvider, for the analyze and draft commands — see that package's README for how it picks among Gemini/Anthropic/OpenAI/OpenRouter/Ollama).