Skip to content

Latest commit

 

History

History
54 lines (34 loc) · 11.3 KB

README.md

File metadata and controls

54 lines (34 loc) · 11.3 KB

apps/cli — the procni command

What this module is responsible for

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 it exists / why this approach

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.

Walkthrough of the key code

  • src/lib/appendGitignore.ts — idempotently ensures .gitignore covers .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's readProjectConfig/writeProjectConfig) → fix up .gitignore → open the database once (which is what actually runs migrations, via @procni/core's openDb) → register the project in the global registry. Every step no-ops correctly if it already happened, so running procni init twice is harmless — see the "idempotent" test case.
  • src/commands/doctor.ts — runDoctor returns a plain array of {name, ok, detail} checks; formatDoctorReport turns that into the ✓/✗ terminal output. Same split-for-testability pattern as init.ts.
  • src/commands/dev.ts — runDev: find the repo, hand off to @procni/server's startDevServer (see apps/server for what that actually does), print the URL, open a browser (skippable with --no-open), and install SIGINT/SIGTERM handlers 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 lets dev.test.ts start a real server, hit it, and call handle.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's runAnalysis directly and synchronously — no job queue, unlike the same pipeline run triggered from the web UI (see packages/core/src/pipeline/README.md for why runAnalysis has 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 to procni draft <id>.
  • src/commands/draft.ts — runDraftCommand: the identical shape one layer down, calling generateDraft instead of runAnalysis. procni draft <opportunityId> prints the finished draft straight to the terminal.
  • src/lib/resolveProject.ts / src/lib/resolveProvider.ts — factored out once draft.ts needed the exact same "find the initialized project" and "resolve a configured AI provider or fail clearly" logic analyze.ts already 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.ts used to be resolveGeminiApiKey.ts, hardcoded to one provider; once @procni/ai grew Anthropic/OpenAI/OpenRouter/Ollama adapters, it was rewritten around @procni/ai's resolveConfiguredProvider so procni analyze/procni draft work with whichever BYOK key (or PROCNI_OLLAMA_BASE_URL) a developer actually has configured, not just Gemini.
  • src/lib/loadEnvFile.ts — loadDotEnvIfPresent, called once at the very top of index.ts before any command runs. Loads a .env file from the current directory via Node's built-in process.loadEnvFile — no dotenv package needed, since Node has shipped this natively since well before this project's Node 22 minimum. This is how PROCNI_GEMINI_API_KEY (see packages/ai/README.md's key-resolution precedence) can live in a .env file instead of needing to be exported in your shell every session.
  • src/index.ts — the only file that touches console.log/process.exitCode. Registers commands with Commander and turns ProcniErrors into a one-line message instead of a stack trace.

How to test it

pnpm --filter procni test

Every 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 doctor

Running it as a real global command

pnpm 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/).

What it depends on / what depends on it

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).