feat(cli-forge): lightweight DI via .provide() and getCommandContext()#79
Merged
AgentEnder merged 26 commits intomainfrom Apr 22, 2026
Merged
feat(cli-forge): lightweight DI via .provide() and getCommandContext()#79AgentEnder merged 26 commits intomainfrom
AgentEnder merged 26 commits intomainfrom
Conversation
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.
Contributor
|
View your CI Pipeline Execution ↗ for commit 503708f
☁️ Nx Cloud last updated this comment at |
Contributor
|
Docs Preview: https://craigory.dev/cli-forge/pr/79/ |
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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
.provide()to CLI for registering eager values or factories (global / executionScope lifetimes)getCommandContext()/inject()for type-safe provider access during handler executionAsyncLocalStoragefor context propagation with a browser fallback stubinject()is not available during middleware/initTestHarness.mockContext()for testing DI providers outside realforge()executionDesign docs
Full design spec and resolved issues at
.ai/plans/dependency-injection/Changes
TProvidersonCLIandInternalCLI— accumulates provider types via.provide()AnyCLI/AnyInternalCLItype aliases replace 45+ scattered<any, any, any, any>castscli-forge/contextentrypoint —getCommandContext(),CommandContext,inject(),ProvidersOf<T>,InferContextOfCommand<T>browserAliasswap for browser buildsTest 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 stubnpx tsx examples/providers/cli.ts deploy --target production --apiUrl http://example.com.provide()works inside command builders