Skip to content

feat(cli-forge): lightweight DI via .provide() and getCommandContext()#79

Merged
AgentEnder merged 26 commits intomainfrom
swanky-cashew
Apr 22, 2026
Merged

feat(cli-forge): lightweight DI via .provide() and getCommandContext()#79
AgentEnder merged 26 commits intomainfrom
swanky-cashew

Conversation

@AgentEnder
Copy link
Copy Markdown
Owner

Summary

  • Adds .provide() to CLI for registering eager values or factories (global / executionScope lifetimes)
  • Adds getCommandContext() / inject() for type-safe provider access during handler execution
  • Uses AsyncLocalStorage for context propagation with a browser fallback stub
  • Providers are deferred to handler phase — inject() is not available during middleware/init
  • Child commands can shadow parent providers; same-level duplicates are a type error
  • TestHarness.mockContext() for testing DI providers outside real forge() execution

Design docs

Full design spec and resolved issues at .ai/plans/dependency-injection/

Changes

  • New 5th generic TProviders on CLI and InternalCLI — accumulates provider types via .provide()
  • AnyCLI / AnyInternalCLI type aliases replace 45+ scattered <any, any, any, any> casts
  • cli-forge/context entrypointgetCommandContext(), CommandContext, inject(), ProvidersOf<T>, InferContextOfCommand<T>
  • AsyncLocalStorage infra with browserAlias swap for browser builds
  • 14 integration tests, 6 mockContext tests, 5 type-test fixtures
  • Multi-file providers example

Test plan

  • nx test cli-forge — 184 tests pass (14 new context tests, 6 new mockContext tests)
  • nx test type-tests — 69+ tests pass (5 new provider type fixtures)
  • nx build cli-forge — clean build, browser bundle includes fallback stub
  • Example: npx tsx examples/providers/cli.ts deploy --target production --apiUrl http://example.com
  • Verify .provide() works inside command builders
  • Verify concurrent SDK calls have isolated contexts

ParsedArgs as the first type parameter caused Set contravariance
issues when assigning `this` to AnyInternalCLI. Also removed
redundant explicit re-export of AnyCLI and fixed bare InternalCLI
in getSubcommands().
Threads a 5th generic TProviders = {} through CLI<>, InternalCLI<>,
ComposableBuilder, and all related types (AnyCLI, AnyInternalCLI,
ExtractCommandName, ExtractCLIChildren, SDKChildren, CLIHandlerContext).
Every method return type passes TProviders through. No behaviour changes.
Adds ProviderConfig and GlobalProviderConfig types, three-overload .provide() method to the CLI interface (eager value, executionScope factory, global factory), and the corresponding InternalCLI implementation with registeredProviders Map and clone() support.
…TestHarness constructor

Addresses code review feedback:
- InternalCLI.middleware() now passes TProviders through in return type
- TestHarness constructor accepts CLI<T, any, any, any, any> for provider-aware CLIs
Creates the cli-forge/context entrypoint with getCommandContext(), CommandContext interface, ProvidersOf and InferContextOfCommand type helpers, and inject()/getChildContext() runtime implementation backed by AsyncLocalStorage.
Creates the cli-forge/context entrypoint with:
- getCommandContext() — reads execution context from AsyncLocalStorage
- CommandContext interface with inject() and getChildContext()
- ProvidersOf<T> and InferContextOfCommand<T> type utilities
- Module-level global provider cache for global-lifetime providers
Wraps handler execution in contextStorage.run() so getCommandContext()
works during handlers. Adds collectProviders() to gather DI providers
from the full command chain. Includes integration tests covering inject,
eager/factory/global providers, commandChain, subcommands, and SDK mode.
Adds static mockContext() and clearMockedContexts() to TestHarness so
unit tests can inject mocked args and providers into the AsyncLocalStorage
context without running a real forge() execution.
- Add ParsedArgs constraint to mockContext TArgs generic
- Fix test args to use valid types instead of arbitrary properties
…defined providers

resolveProvider() now returns a NOT_FOUND symbol instead of undefined
when no provider is registered, preventing false "not found" errors
for factories that legitimately return undefined.

Also fixes fetch references in type tests.
…builders

The builder parameter type used `any` for TProviders, causing
keyof any = string|number|symbol, which made the duplicate-key
constraint always resolve to `never`. Using {} means all provider
names are valid in builders.

Also adds providers example demonstrating the DI pattern.
@nx-cloud
Copy link
Copy Markdown
Contributor

nx-cloud Bot commented Apr 11, 2026

View your CI Pipeline Execution ↗ for commit 503708f

Command Status Duration Result
nx run-many -t build test e2e lint ✅ Succeeded 5m 42s View ↗
nx build docs-site ✅ Succeeded 1m 18s View ↗

☁️ Nx Cloud last updated this comment at 2026-04-12 20:25:54 UTC

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 11, 2026

Docs Preview: https://craigory.dev/cli-forge/pr/79/

Deployed commit: 503708f (503708f)

github-actions Bot added a commit that referenced this pull request Apr 11, 2026
claude added 6 commits April 12, 2026 08:22
Adds a multi-file example under `examples/di-logger/` that registers a
`logger` provider whose factory reads `args.logLevel`, so any command
handler that `inject('logger')`s automatically gets level filtering
driven by whatever the user passed on the command line. Three test
cases cover the default (info), debug, and warn levels.

https://claude.ai/code/session_015n83MfiYyQxmDXSztuEE62
- Thread TChildProviders through .command() so child-overrides-parent
  provider types survive TChildren tracking. ProvidersOf<T> now uses
  Omit when intersecting parent's chain, and CommandToChildEntry /
  CLICommandOptions carry the inferred child providers up from the
  builder's return type.
- Stamp every InternalCLI with a unique commandId at construction and
  thread it into ForgeContextData.commandIdChain. getCommandContext(cli)
  now validates that the passed CLI is part of the active chain (the
  root, an ancestor, or the running command); a mismatched witness
  throws with a descriptive error instead of silently returning the
  wrong providers. clone() propagates the id so SDK-clone callsites
  stay valid.
- Add cycle detection to provider factory resolution via
  ForgeContextData.resolving: a factory that injects into itself
  (directly or indirectly) now throws with the cycle chain instead of
  blowing the stack.
- Export resetGlobalProviders() from cli-forge/context for test
  isolation of lifetime: 'global' factories.

https://claude.ai/code/session_015n83MfiYyQxmDXSztuEE62
…dContext

- TestHarness.mockContext now detects { factory } entries in options.providers
  and populates contextData.providerFactories, mirroring .provide() runtime
  semantics. Factory mocks run lazily via inject() and receive the mocked args.
- Add TestHarness.runWithMockedContext(cli, options, fn) which scopes the
  mocked context via AsyncLocalStorage.run(), so awaits inside fn keep seeing
  the mock and teardown is automatic on resolve/throw. Preferred over
  mockContext for anything that crosses an async boundary.
- Add TestHarness.resetGlobalProviders() and have clearMockedContexts() call
  it automatically, so the common afterEach pattern gives a clean slate for
  lifetime: 'global' providers.
- Type the providers option as the same shape .provide() accepts (eager
  values, executionScope factories, global factories) so factory mocks are
  expressible without casts.

https://claude.ai/code/session_015n83MfiYyQxmDXSztuEE62
…dates

Adds docs-site/docs/guides/dependency-injection.md covering the DI model,
handler-in-separate-file pattern, child-override semantics, both getChildContext
and composed-subcommand access patterns, runtime validation via commandIdChain,
testing with mockContext/runWithMockedContext/resetGlobalProviders, and the
browser concurrency caveat.

Update examples/di-logger/build.ts and examples/providers/deploy.ts inline
comments to reflect that getCommandContext(app) is now runtime-safe: the CLI
reference is validated against the active commandIdChain, so passing the root
from a subcommand handler is still the idiomatic pattern.

https://claude.ai/code/session_015n83MfiYyQxmDXSztuEE62
…ders aren't inherited

Revise the "Reaching a subcommand's typed context" section of the DI guide
to lead with the most ergonomic pattern — declare the subcommand as a
standalone CLI, import it from the handler's module, and pass it straight
to getCommandContext(sub) — and explicitly note that this is preferred
whenever the subcommand doesn't rely on root-inherited providers. The
inherited-providers case (standalone's TParent erases the parent chain)
keeps its two alternatives (getChildContext, inline + getChildren), but
they're now framed as fallbacks for the specific type-surface problem they
solve. Adds a decision-guide table.

https://claude.ai/code/session_015n83MfiYyQxmDXSztuEE62
- Key the global provider cache by factory function identity instead of
  by provider name. Two unrelated CLIs (or two registrations of the same
  key with different factories) no longer collide on a shared cached
  value. New context.spec.ts case exercises cross-CLI isolation.
- Correct the TestHarness.mockContext JSDoc to match the implementation:
  the helper does not stack nested contexts and cleanup blanks the store
  rather than restoring a previous one. Point users at
  runWithMockedContext for proper async/nested scoping.
- Update examples/di-logger/content.md to note that the CLI passed to
  getCommandContext is used as both a type witness and a runtime
  identity check via the commandIdChain, not purely compile-time.

https://claude.ai/code/session_015n83MfiYyQxmDXSztuEE62
github-actions Bot added a commit that referenced this pull request Apr 12, 2026
claude added 3 commits April 12, 2026 16:14
Adds a third way to access the execution context for a running command,
as a lightweight getter on the CLI instance itself. It's a shorthand for
getCommandContext(this) that keeps the same runtime chain validation and
type inference, but removes the need for a separate import when the CLI
reference is already in scope.
Adds a vitest spec that compiles inline snippets mirroring the
docblock example on CLI.getContext() and asserts — via tsc AST
introspection — that neither the returned context, its args, its
commandChain, nor inject('db') collapses to any. Sanity-checked
by temporarily replacing the return type with any, which correctly
fails all four cases.
… type

Introduces a ProvidersFromChain<TProviders, TParent> helper that walks
the parent chain from the raw type parameters, and uses it in
CLI.getContext()'s return type instead of
ProvidersOf<CLI<TArgs, THandlerReturn, TChildren, TParent, TProviders>>.
This avoids re-wrapping the interface's own type parameters in a
CLI<...> while the interface is still being constructed — a pattern
that has bitten the constraint solver on past work — and keeps the
recursion in conditional-type space where it evaluates lazily at call
sites.
github-actions Bot added a commit that referenced this pull request Apr 12, 2026
@AgentEnder AgentEnder marked this pull request as ready for review April 22, 2026 03:27
@AgentEnder AgentEnder merged commit 68908b2 into main Apr 22, 2026
4 checks passed
@AgentEnder AgentEnder deleted the swanky-cashew branch April 22, 2026 03:27
github-actions Bot added a commit that referenced this pull request Apr 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants