Skip to content

feat(providers): multi-provider AI architecture#171

Merged
snipcodeit merged 19 commits intomainfrom
issue/157-multi-provider-ai-architecture-resear
Mar 5, 2026
Merged

feat(providers): multi-provider AI architecture#171
snipcodeit merged 19 commits intomainfrom
issue/157-multi-provider-ai-architecture-resear

Conversation

@snipcodeit
Copy link
Owner

Summary

  • Defined a provider interface and refactored lib/claude.cjs into lib/provider-claude.cjs with a backward-compat shim
  • Implemented GeminiProvider and OpenCodeProvider with CLI-specific invocation and commands directory conventions
  • Generalized lib/gsd-adapter.cjs with a 3-tier resolveGsdRoot() lookup (env var > config > default)
  • Updated bin/mgw-install.cjs to auto-detect installed AI CLIs and support --provider override

Closes #157

Changes

Phase 32 — Provider Interface Design

  • lib/provider-claude.cjs: New canonical Claude CLI provider with assertAvailable(), invoke(), and getCommandsDir()
  • lib/claude.cjs: Reduced to backward-compat shim re-exporting all legacy aliases
  • lib/provider-manager.cjs: New ProviderManager registry with getProvider(), listProviders(), and resolveDefault()
  • bin/mgw.cjs / lib/index.cjs: Wired through ProviderManager with --provider flag support

Phase 33 — Gemini + OpenCode Providers

  • lib/provider-gemini.cjs: Gemini CLI provider — inline system block injection, ~/.gemini/commands/ directory
  • lib/provider-opencode.cjs: OpenCode provider — opencode run with --system-prompt flag, ~/.opencode/commands/ directory
  • lib/provider-manager.cjs: All three providers registered (claude, gemini, opencode)

Phase 34 — GSD Adapter Generalization

  • lib/gsd-adapter.cjs: Added resolveGsdRoot() with 3-tier path resolution: GSD_TOOLS_PATH env var → config.gsd_path → default ~/.claude/get-shit-done

Phase 35 — Command Installation Multi-CLI Support

  • bin/mgw-install.cjs: Auto-detect installed CLI (claude → gemini → opencode priority), --provider flag override, old commands directory cleanup on provider switch

Test Plan

  • node bin/mgw.cjs --provider claude invokes Claude CLI correctly
  • node bin/mgw.cjs --provider gemini invokes Gemini CLI correctly
  • node bin/mgw.cjs --provider opencode invokes OpenCode CLI correctly
  • node bin/mgw-install.cjs auto-detects installed CLI and installs to correct commands dir
  • node bin/mgw-install.cjs --provider gemini overrides auto-detection
  • GSD_TOOLS_PATH=/custom/path node bin/mgw.cjs resolves GSD root from env var
  • resolveGsdRoot() falls back to config gsd_path when env var unset
  • resolveGsdRoot() falls back to ~/.claude/get-shit-done default
  • All existing Claude-based workflows continue working (backward compat via shim)
  • lib/provider-manager.cjs listProviders() returns all three registered providers

Human Verification Required

The following Phase 33 items require real CLI binaries and cannot be verified in automated tests:

  1. Gemini CLI end-to-end: Install gemini CLI, run node bin/mgw.cjs --provider gemini and verify a real command executes with the correct inline system block injection
  2. OpenCode end-to-end: Install opencode CLI, run node bin/mgw.cjs --provider opencode and verify opencode run --system-prompt is invoked correctly with MGW context
  3. mgw:run on Gemini CLI: Run the full mgw:run pipeline with --provider gemini against a test issue to confirm the triage → execute → PR flow works end-to-end with Gemini

snipcodeit and others added 15 commits March 5, 2026 00:18
- Add PROVIDER_ID = 'claude' constant
- Rename assertClaudeAvailable -> assertAvailable (generic contract name)
- Rename invokeClaude -> invoke (generic contract name)
- Keep getCommandsDir unchanged
- Add provider interface contract JSDoc block
- Pure rename/restructure pass, no logic changes
- Re-exports all from lib/provider-claude.cjs via spread
- Adds legacy aliases assertClaudeAvailable and invokeClaude
- bin/mgw.cjs and lib/index.cjs continue to work without changes
- Created 32-01-SUMMARY.md documenting plan completion
- 2 tasks executed, 2 files modified (provider-claude.cjs created, claude.cjs shimmed)
…solution

- Registry maps PROVIDER_ID strings to provider modules
- getProvider(id) defaults to 'claude', throws on unknown with helpful message
- listProviders() returns array of registered IDs
- Pre-populated with claude provider
- Replace direct lib/claude.cjs import in bin/mgw.cjs with ProviderManager
- Add --provider global flag to mgw CLI for future provider selection
- Route all provider calls through provider.assertAvailable(), provider.invoke(), provider.getCommandsDir()
- Fix help command getCommandsDir() call to route through ProviderManager.getProvider()
- Add ProviderManager to lib/index.cjs barrel export

[Rule 1 - Bug] Fixed help command calling bare getCommandsDir() which was
no longer imported after switching to ProviderManager import.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- 32-02-SUMMARY.md documents ProviderManager creation and bin/mgw.cjs wiring
- Phase 32 plans 01 and 02 both complete with all success criteria met
- Provider interface abstraction layer fully operational

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ementation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- PROVIDER_ID='gemini'
- assertAvailable() checks gemini binary via execSync; exits with install instructions on ENOENT
- getCommandsDir() returns ~/.gemini/commands; throws clear message if missing
- invoke() prepends commandFile contents as <system> block (no --system-prompt-file in gemini CLI)
- invoke() supports --model flag, quiet buffering, dry-run mode
- opts.json ignored (gemini CLI has no --output-format json equivalent)
- PROVIDER_ID='opencode'
- assertAvailable() checks opencode binary via execSync; exits with install instructions on ENOENT
- getCommandsDir() returns ~/.opencode/commands; throws clear message if missing
- invoke() uses 'opencode run' subcommand as non-interactive mode
- invoke() passes commandFile via --system-prompt flag (opencode native support)
- invoke() supports --model flag, quiet buffering, dry-run mode
- opts.json ignored (opencode CLI has no --output-format json equivalent)
…Manager registry

- Added gemini: require('./provider-gemini.cjs') to registry dict
- Added opencode: require('./provider-opencode.cjs') to registry dict
- Updated JSDoc to list all three provider IDs
- getProvider('unknown') error message auto-lists all three providers via Object.keys()
- ProviderManager.listProviders() now returns ['claude', 'gemini', 'opencode']
…summaries

- 33-01-SUMMARY.md: GeminiProvider with inline system-prompt injection
- 33-02-SUMMARY.md: OpenCodeProvider with native --system-prompt file support
- 33-03-SUMMARY.md: ProviderManager registry updated with all three providers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add resolveGsdRoot() with tier 1 (GSD_TOOLS_PATH env), tier 2 (.mgw/config.json gsd_path), tier 3 (default ~/.claude/get-shit-done/)
- Update getGsdToolsPath() to use resolveGsdRoot() internally
- Error message now lists all checked paths when binary not found
- Export resolveGsdRoot for callers needing workflow path construction
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Auto-detects active AI CLI binary (claude > gemini > opencode priority)
- --provider flag overrides auto-detection (both = and space forms)
- PROVIDER_TARGETS map routes to ~/.claude/commands/mgw/, ~/.gemini/commands/mgw/, ~/.opencode/commands/
- Reads/writes ~/.mgw-install-state.json to detect provider switches
- Removes old provider install dir when switching providers (fs.rmSync)
- Prints skip message and exits 0 when no CLI found or base dir missing
- Invalid --provider value exits 1 with list of valid options
@github-actions github-actions bot added the core Changes to core library label Mar 5, 2026
@snipcodeit
Copy link
Owner Author

Testing Procedures

Step-by-step instructions to verify the multi-provider AI architecture.

Prerequisites

# Clone/navigate to the repo
cd /path/to/mgw

# Ensure dependencies are installed
npm install

1. Provider Manager — Unit Verification

node -e "
const { ProviderManager } = require('./lib/provider-manager.cjs');
const pm = new ProviderManager();
console.log('Registered providers:', pm.listProviders());
// Expected: ['claude', 'gemini', 'opencode']
const p = pm.getProvider('claude');
console.log('Claude provider:', p.name);
"

2. Backward Compatibility Shim

node -e "
const claude = require('./lib/claude.cjs');
console.log('invoke exported:', typeof claude.invoke);
console.log('assertAvailable exported:', typeof claude.assertAvailable);
// Both should print 'function'
"

3. GSD Adapter — resolveGsdRoot() 3-Tier Lookup

# Tier 1: env var
GSD_TOOLS_PATH=/tmp/custom-gsd node -e "
const { resolveGsdRoot } = require('./lib/gsd-adapter.cjs');
console.log(resolveGsdRoot({}));
// Expected: /tmp/custom-gsd
"

# Tier 2: config value (unset env var)
node -e "
const { resolveGsdRoot } = require('./lib/gsd-adapter.cjs');
console.log(resolveGsdRoot({ gsd_path: '/tmp/config-gsd' }));
// Expected: /tmp/config-gsd
"

# Tier 3: default fallback
node -e "
const { resolveGsdRoot } = require('./lib/gsd-adapter.cjs');
console.log(resolveGsdRoot({}));
// Expected: ~/.claude/get-shit-done (expanded path)
"

4. mgw-install — Auto-Detection

# Dry run with Claude (assuming claude CLI is installed)
node bin/mgw-install.cjs --dry-run
# Expected: detects 'claude', target dir is ~/.claude/commands/mgw/

# Force a specific provider
node bin/mgw-install.cjs --provider gemini --dry-run
# Expected: target dir is ~/.gemini/commands/mgw/

node bin/mgw-install.cjs --provider opencode --dry-run
# Expected: target dir is ~/.opencode/commands/mgw/

5. Claude Provider — Existing Workflow (Regression)

# Verify mgw still works with Claude (default)
node bin/mgw.cjs help
# Expected: help output displayed, no errors

6. Human-Required: Gemini CLI End-to-End

Requires gemini CLI installed and authenticated.

node bin/mgw.cjs --provider gemini mgw:help
# Expected: Gemini CLI executes with system block injected inline
# Verify in Gemini CLI output that MGW context appears at the start of the prompt

7. Human-Required: OpenCode End-to-End

Requires opencode CLI installed and authenticated.

node bin/mgw.cjs --provider opencode mgw:help
# Expected: opencode run --system-prompt <mgw-context> is invoked
# Verify in process args / opencode logs that --system-prompt flag was passed

8. Human-Required: Full mgw:run Pipeline on Gemini

Requires Gemini CLI installed, authenticated, and a test GitHub issue.

# Set provider and run against a test issue
node bin/mgw.cjs --provider gemini mgw:run --issue <TEST_ISSUE_NUMBER>
# Expected: full triage → planning → execution → PR flow completes
# Check .mgw/active/<issue>.json transitions through pipeline stages

Pass Criteria

Test Pass Condition
Provider registry All 3 providers listed
Shim compat invoke and assertAvailable exported
GSD root tier 1 Returns env var path
GSD root tier 2 Returns config path
GSD root tier 3 Returns default path
mgw-install auto-detect Correct dir per detected CLI
Claude regression mgw:help works without --provider flag
Gemini e2e (human) System block injected, command runs
OpenCode e2e (human) --system-prompt flag passed correctly
mgw:run on Gemini (human) Full pipeline completes

- Gemini getCommandsDir() returned ~/.gemini/commands/ but installer
  copies to ~/.gemini/commands/mgw/ — commands would never be found
  at runtime. Fixed to return ~/.gemini/commands/mgw/.

- OpenCode install target lacked mgw/ namespace — commands were mixed
  into ~/.opencode/commands/ and provider-switch cleanup (rmSync) would
  destroy ALL opencode commands. Added mgw/ subdirectory to both the
  install target and getCommandsDir().

- mgw-install.cjs parent dir check used path.dirname(targetDir) which
  resolved to ~/.claude/commands/ for claude (too strict vs original
  ~/.claude/ check). Now checks ~/.<provider>/ directly, matching the
  skip message.

- Fixed var → const/let inconsistency in provider-opencode.cjs invoke().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@snipcodeit
Copy link
Owner Author

Review Fixes — 97e9df7

Reviewed all 19 changed files. Found 4 bugs (2 critical, 1 medium, 1 minor) and pushed a fix commit.

Critical: Gemini getCommandsDir() path mismatch

provider-gemini.cjs returned ~/.gemini/commands/ but mgw-install.cjs copies to ~/.gemini/commands/mgw/. At runtime, runAiCommand would look for ~/.gemini/commands/run.md but the file lives at ~/.gemini/commands/mgw/run.md. Commands would never be found.

Fix: Changed getCommandsDir() to return ~/.gemini/commands/mgw/.

Critical: OpenCode install target lacks mgw/ namespace

Install target was ~/.opencode/commands/ (no mgw/ subdirectory), while claude and gemini both use mgw/ for isolation. This caused two problems:

  1. MGW commands would mix with any existing opencode commands
  2. On provider switch, fs.rmSync(~/.opencode/commands/, { recursive: true, force: true }) would delete ALL opencode commands, not just MGW ones

Fix: Added mgw/ subdirectory to both the install target and getCommandsDir().

Medium: Parent dir guard too strict + misleading message

mgw-install.cjs used path.dirname(targetDir) to check if the provider's base directory exists. For claude, this resolved to ~/.claude/commands/ — stricter than the original ~/.claude/ check. The skip message said ~/.<provider>/ not found but wasn't checking that path.

Fix: Now checks ~/.<provider>/ directly via path.join(os.homedir(), '.' + detectedProvider), matching both the original behavior and the message.

Minor: var usage in provider-opencode.cjs

The invoke() Promise callback used var (lines 109, 111, 113) while all other new code uses const/let.

Fix: Changed to const/let.

…ini dry-run dumps prompt

1. help command: ProviderManager.getProvider() was called with no argument,
   always defaulting to claude even when --provider flag was passed. Now
   reads this.optsWithGlobals().provider and forwards it.

2. Gemini/OpenCode getCommandsDir() error messages referenced a non-existent
   'mgw install-commands' subcommand. Fixed to reference the actual installer
   script: node bin/mgw-install.cjs --provider <id>.

3. Gemini invoke() dry-run check ran AFTER reading the command file and
   embedding its full contents into the prompt args. Dry-run output would
   dump the entire system prompt (potentially many KB). Moved dry-run
   check before file I/O; dry-run now shows the file path reference.

4. Stale comments in bin/mgw.cjs still referenced assertClaudeAvailable()
   and 'claude -p' after the provider refactor. Updated to generic terms.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@snipcodeit
Copy link
Owner Author

Review Findings — Patched in 1158cd2

Thorough review of all 19 changed files. Four bugs found and fixed:

1. help command ignores --provider flag (bug)

File: bin/mgw.cjs:511
ProviderManager.getProvider() was called with no argument, always defaulting to claude regardless of --provider flag. Running mgw --provider gemini help would still read help.md from Claude's commands directory.

Fix: Added const opts = this.optsWithGlobals() and passed opts.provider to getProvider().

2. Non-existent mgw install-commands in error messages (false claim)

Files: provider-gemini.cjs:33, provider-opencode.cjs:33
Both getCommandsDir() error messages told users to run mgw install-commands --provider <id> — a subcommand that doesn't exist anywhere in the codebase. The actual install mechanism is node bin/mgw-install.cjs --provider <id> or npm postinstall.

Fix: Updated error messages to reference the actual installer script.

3. Gemini dry-run dumps entire system prompt (bug)

File: provider-gemini.cjs:107-109
The dry-run check was positioned after fs.readFileSync(commandFile) and the full file contents were embedded into effectivePromptargs. Running --dry-run would print the entire system prompt (potentially many KB of markdown) as one line, making the output unreadable. Compare to Claude and OpenCode providers where dry-run shows concise file path references.

Fix: Moved dry-run check before file I/O. Dry-run now shows the command file path instead of its contents.

4. Stale comments referencing pre-refactor code (cosmetic)

File: bin/mgw.cjs:8-9, 49-50
Comments still referenced assertClaudeAvailable(), claude -p, and "without claude installed" after the provider abstraction refactor.

Fix: Updated to generic provider terminology.

Items verified as correct

  • claude.cjs shim correctly preserves all three original exports (assertClaudeAvailable, invokeClaude, getCommandsDir) via spread + legacy aliases
  • ProviderManager registry pattern and default-to-claude behavior
  • resolveGsdRoot() 3-tier lookup (env → config → default) and integration with getGsdToolsPath()
  • mgw-install.cjs provider-switch cleanup removes only the mgw/ subdirectory (not the entire commands dir)
  • spawn usage (array args, not shell strings) — no injection risk
  • lib/index.cjs barrel export — no naming collisions between spread modules

1. mgw-install.cjs: Move parent-dir guard BEFORE old-dir cleanup.
   Previously, switching providers (e.g. --provider gemini) when the
   new provider's home dir didn't exist would delete old commands
   (rmSync) then exit 0 at the guard — leaving zero commands installed
   for any provider.

2. claude.cjs shim: Replace ...provider spread with explicit exports.
   The spread leaked PROVIDER_ID, assertAvailable, invoke into the
   shim module (and therefore the barrel). These generic names weren't
   in the original API surface and could shadow or be shadowed by
   future barrel modules. Shim now exports only the original three:
   assertClaudeAvailable, invokeClaude, getCommandsDir.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@snipcodeit
Copy link
Owner Author

Review Pass 3 — Patched in e4ac26b

Reviewed all 19 changed files line-by-line. Two code bugs and one false claim found.

1. Installer cleanup-before-guard ordering (bug)

File: bin/mgw-install.cjs:143–167

Old-dir cleanup (rmSync) ran before the parent-dir guard that checks whether the new provider's home directory exists. Scenario:

  1. User has Claude installed, runs mgw-install.cjs → commands at ~/.claude/commands/mgw/, state records claude
  2. User runs mgw-install.cjs --provider gemini but ~/.gemini/ doesn't exist
  3. Cleanup deletes ~/.claude/commands/mgw/
  4. Parent-dir guard fails → exits 0 with "skipping"
  5. User now has zero commands for any provider

Fix: Swapped the two blocks — parent-dir guard runs first, cleanup only runs after we've confirmed the new target is viable.

2. Shim namespace leak into barrel (bug)

File: lib/claude.cjs

The backward-compat shim used ...provider spread, which exported the new generic names (PROVIDER_ID, assertAvailable, invoke) alongside the original three (assertClaudeAvailable, invokeClaude, getCommandsDir). Since the barrel (lib/index.cjs) does ...require('./claude.cjs'), those generic names leaked into the barrel export namespace.

Why this matters:

  • invoke is a very common name — any future module added to the barrel exporting invoke would silently shadow or be shadowed
  • Callers doing const { invoke } = require('./lib/index.cjs') would get Claude's invoke, bypassing the provider system entirely
  • The original API surface was exactly three names; the shim shouldn't introduce new ones

Fix: Replaced ...provider spread with explicit named exports matching the original API surface exactly.

3. PR description mentions non-existent resolveDefault() (false claim)

The PR body says:

lib/provider-manager.cjs: New ProviderManager registry with getProvider(), listProviders(), and resolveDefault()

resolveDefault() doesn't exist anywhere in the codebase. The default-to-claude behavior is folded into getProvider()'s fallback logic.

Items verified as correct (spot checks)

  • ProviderManager.getProvider() default-to-claude behavior
  • resolveGsdRoot() 3-tier lookup reads from env/disk/default (no parameter needed)
  • getCommandsDir() paths for all three providers match installer targets
  • runAiCommand correctly forwards opts.provider through all AI commands
  • help command forwards opts.provider to getProvider() (fixed in pass 2)
  • Gemini dry-run positioned before file I/O (fixed in pass 2)
  • Error messages reference correct installer script (fixed in pass 2)
  • Provider-switch cleanup targets mgw/ subdirectory only (fixed in pass 1)
  • spawn() uses array args — no injection risk
  • Synchronous throw from invoke() correctly propagates through async/await in runAiCommand

Merge main into issue/157 branch, resolving conflicts in 4 files:
- lib/claude.cjs: keep shim pattern, delegate to provider-claude.cjs
- lib/provider-claude.cjs: add error classes, timeouts, SIGINT handler from main
- lib/gsd-adapter.cjs: auto-merged (resolveGsdRoot + error types + STAGES)
- lib/index.cjs: auto-merged (provider-manager + pipeline/errors/logger + lazy TUI)
- bin/mgw.cjs: auto-merged (ProviderManager + startTimer/logger)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@snipcodeit snipcodeit merged commit 5186218 into main Mar 5, 2026
6 checks passed
@snipcodeit snipcodeit deleted the issue/157-multi-provider-ai-architecture-resear branch March 5, 2026 21:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core Changes to core library

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Multi-Provider AI Architecture Research & Detail

1 participant