From b3b883f1d7af43301d2e1780c50d1ddf6d3ec60a Mon Sep 17 00:00:00 2001 From: Daniel Wise Date: Sun, 8 Mar 2026 14:02:21 -0700 Subject: [PATCH 1/2] feat: add Amazon Bedrock support and guided ai setup --- .beads/dolt-monitor.pid.lock | 0 QUICKSTART.md | 41 +- README.md | 88 +- apps/docs/content/docs/commands/create.mdx | 2 +- apps/docs/content/docs/commands/docs.mdx | 12 + apps/docs/content/docs/commands/meta.json | 2 + apps/docs/content/docs/commands/repo.mdx | 14 + apps/docs/content/docs/commands/submit.mdx | 2 + .../docs/getting-started/quickstart.mdx | 22 +- .../docs/content/docs/guides/ai-assistant.mdx | 178 +-- packages/cli/evals/dub-flow-metadata.eval.ts | 13 + packages/cli/package.json | 5 + packages/cli/src/commands/ai-env.test.ts | 43 +- packages/cli/src/commands/ai-env.ts | 129 +- packages/cli/src/commands/ai-resolve.test.ts | 36 + packages/cli/src/commands/ai-resolve.ts | 72 +- packages/cli/src/commands/ai-setup.test.ts | 133 ++ packages/cli/src/commands/ai-setup.ts | 226 ++++ packages/cli/src/commands/ai.test.ts | 72 +- packages/cli/src/commands/ai.ts | 76 +- packages/cli/src/commands/config.test.ts | 72 +- packages/cli/src/commands/config.ts | 123 ++ packages/cli/src/commands/create.ts | 6 + packages/cli/src/commands/docs.test.ts | 23 + packages/cli/src/commands/docs.ts | 6 + packages/cli/src/commands/flow.ts | 6 + packages/cli/src/commands/repo.test.ts | 24 + packages/cli/src/commands/repo.ts | 5 + packages/cli/src/commands/submit.test.ts | 106 +- packages/cli/src/commands/submit.ts | 10 + packages/cli/src/index.ts | 156 ++- packages/cli/src/lib/ai-metadata.test.ts | 121 +- packages/cli/src/lib/ai-metadata.ts | 61 +- packages/cli/src/lib/ai-provider.test.ts | 223 ++++ packages/cli/src/lib/ai-provider.ts | 214 ++++ packages/cli/src/lib/browser.test.ts | 49 + packages/cli/src/lib/browser.ts | 23 + packages/cli/src/lib/config.test.ts | 82 ++ packages/cli/src/lib/config.ts | 45 + packages/cli/src/lib/external-links.ts | 1 + packages/cli/src/lib/github.test.ts | 69 ++ packages/cli/src/lib/github.ts | 67 + pnpm-lock.yaml | 1100 +++++++++++++++++ 43 files changed, 3431 insertions(+), 327 deletions(-) create mode 100644 .beads/dolt-monitor.pid.lock create mode 100644 apps/docs/content/docs/commands/docs.mdx create mode 100644 apps/docs/content/docs/commands/repo.mdx create mode 100644 packages/cli/src/commands/ai-setup.test.ts create mode 100644 packages/cli/src/commands/ai-setup.ts create mode 100644 packages/cli/src/commands/docs.test.ts create mode 100644 packages/cli/src/commands/docs.ts create mode 100644 packages/cli/src/commands/repo.test.ts create mode 100644 packages/cli/src/commands/repo.ts create mode 100644 packages/cli/src/lib/ai-provider.test.ts create mode 100644 packages/cli/src/lib/ai-provider.ts create mode 100644 packages/cli/src/lib/browser.test.ts create mode 100644 packages/cli/src/lib/browser.ts create mode 100644 packages/cli/src/lib/external-links.ts diff --git a/.beads/dolt-monitor.pid.lock b/.beads/dolt-monitor.pid.lock new file mode 100644 index 0000000..e69de29 diff --git a/QUICKSTART.md b/QUICKSTART.md index db834d7..6f71c87 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -11,29 +11,48 @@ This guide gets you from zero to a working stacked PR flow fast. ## Optional: Enable AI Assistant ```bash -# 1) add one API key to your shell profile -dub ai env --gemini-key "" -# or: -dub ai env --gateway-key "" +# 1) guided setup for Gemini, AI Gateway, or Amazon Bedrock +dub ai setup -# 2) reload your shell +# 2) reload your shell using the command DubStack prints source ~/.zshrc # 3) enable assistant for this repo dub config ai-assistant on -# 4) optional: enable AI defaults +# 4) pin the provider for this repository +dub config ai-provider gemini +# or: +dub config ai-provider gateway +# or: +dub config ai-provider bedrock + +# 5) optional: enable AI defaults dub config ai-defaults create on dub config ai-defaults submit on dub config ai-defaults flow on -# 5) ask a question +# 6) ask a question dub ai ask "Summarize this stack from trunk to current branch" # optional: inspect recent dub command history/context dub history --limit 20 ``` +For Bedrock teams using AWS SSO or role-based auth, the secure non-interactive setup looks like this: + +```bash +dub ai env \ + --bedrock-profile "bw-sso" \ + --bedrock-region "us-west-2" \ + --bedrock-model "us.anthropic.claude-sonnet-4-6" + +dub config ai-provider bedrock +``` + +DubStack does not add or manage AWS secret key environment variables for Bedrock. +`dub ai setup` and `dub ai env` print the exact activation command to run after updating your shell profile. + Optional template setup: ```bash @@ -136,6 +155,10 @@ Open PR in browser: dub pr # current branch PR dub pr 123 # explicit PR dub pr feat/x # explicit branch + +# product shortcuts +dub docs # DubStack docs +dub repo # current repository GitHub page ``` ## 5) Respond to Feedback @@ -284,6 +307,8 @@ dub undo | `dub co` | Interactive checkout | | `dub ss` | Submit stack PRs | | `dub pr` | Open PR in browser | +| `dub docs` | Open the DubStack docs site | +| `dub repo` | Open the current repository GitHub page | | `dub sync` | Sync local state with remote | | `dub doctor` | Run stack health checks | | `dub ready` | Run pre-submit checklist | @@ -298,6 +323,8 @@ dub undo | `dub continue` / `dub abort` | Resume/cancel interrupted operations | | `dub undo` | Undo last create/restack | | `dub config ai-assistant on` | Enable repo-local AI assistant | +| `dub config ai-provider bedrock` | Pin the repo-local AI provider | +| `dub ai setup` | Guided provider/model/env setup | | `dub ai ask "..."` | Ask AI assistant (streaming + constrained read-only repo shell tool) | | `dub flow --ai -a` | Stage, preview, create, and submit with AI | | `dub history` | Show recent Dub command history | diff --git a/README.md b/README.md index 5f591fb..f26cf2d 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,10 @@ dub ss # 5) Open PR for current branch dub pr + +# 6) Open docs or the current repository homepage +dub docs +dub repo ``` For a more detailed walkthrough, see [`QUICKSTART.md`](./QUICKSTART.md). @@ -120,6 +124,22 @@ Notes: - `dub create` auto-initializes state if needed. - Running `dub init` manually is still useful for explicit setup. +### `dub docs` + +Open the DubStack docs site in your browser. + +```bash +dub docs +``` + +### `dub repo` + +Open the current repository GitHub page in your browser. + +```bash +dub repo +``` + ### `dub create [branch]` Create a branch stacked on top of the current branch. @@ -611,6 +631,36 @@ dub config ai-defaults submit on dub config ai-defaults flow on ``` +### `dub config ai-provider [auto|gemini|gateway|bedrock]` + +Manage the repo-local AI provider selection. + +```bash +# inspect current provider +dub config ai-provider + +# pin this repository to Bedrock +dub config ai-provider bedrock + +# return to backward-compatible auto selection +dub config ai-provider auto +``` + +### `dub config ai-model [model] --provider ` + +Manage repo-local model overrides by provider. + +```bash +# inspect current Bedrock override +dub config ai-model --provider bedrock + +# set a repo-local override +dub config ai-model "us.anthropic.claude-sonnet-4-6" --provider bedrock + +# clear the repo-local override +dub config ai-model --provider bedrock --clear +``` + ### `dub ai ask ` Ask DubStack's AI assistant using streaming output (`streamText`). @@ -625,10 +675,14 @@ In TTY mode, response text streams live while status/tool activity lines are ren To inspect your repository, `dub ai ask` can invoke a constrained shell tool limited to a strict allow-list of safe, read-only commands (for example `git status`, `dub doctor`, `dub ready`) when command output is needed. The assistant cannot execute arbitrary shell commands; requests outside this allow-list are rejected, and additional safety checks block destructive command patterns. -Provider/key selection: -- If `DUBSTACK_GEMINI_API_KEY` is set, DubStack uses direct Google provider access with model from `DUBSTACK_GEMINI_MODEL` (default: `gemini-3-flash-preview`). -- Otherwise, if `DUBSTACK_AI_GATEWAY_API_KEY` is set, DubStack uses Vercel AI Gateway with model from `DUBSTACK_AI_GATEWAY_MODEL` (default: `google/gemini-3-flash`). -- If both are set, DubStack prefers `DUBSTACK_GEMINI_API_KEY`. +Provider/model selection: +- Repo config from `dub config ai-provider ...` wins when set to `gemini`, `gateway`, or `bedrock`. +- Repo-local model overrides from `dub config ai-model ...` win for that provider when present. +- In `auto` mode, DubStack preserves the legacy fallback order: Gemini, then AI Gateway, then Bedrock. +- Gemini uses `DUBSTACK_GEMINI_API_KEY` with optional `DUBSTACK_GEMINI_MODEL` override. +- AI Gateway uses `DUBSTACK_AI_GATEWAY_API_KEY` with optional `DUBSTACK_AI_GATEWAY_MODEL` override. +- Bedrock uses `DUBSTACK_BEDROCK_AWS_REGION`, `DUBSTACK_BEDROCK_MODEL`, and optional `DUBSTACK_BEDROCK_AWS_PROFILE`. +- Bedrock support uses AWS credential-chain auth only. DubStack does not manage AWS secret key environment variables. Thinking is enabled by default for Gemini 3 Flash. @@ -656,9 +710,25 @@ pnpm evals:export The first suite lives at `packages/cli/evals/dub-flow-metadata.eval.ts` and evaluates the pure `generateFlowMetadata(...)` helper used by `dub flow`. It mixes deterministic contract checks with an AI judge scorer so prompt changes are measured against staged diff fidelity, template preservation, and reviewer usefulness. +### `dub ai setup` + +Run the guided setup flow for Gemini, AI Gateway, or Amazon Bedrock. + +```bash +dub ai setup +``` + +The setup wizard helps you: +- choose the repo-local provider +- choose a curated model or enter a custom model ID +- write global provider defaults into your shell profile +- optionally store a repo-local model override + +When the wizard writes env vars, DubStack loads them into the current `dub` process and prints the exact command to run in your shell to activate them immediately. + ### `dub ai env` -Write DubStack AI keys/models into your shell profile (macOS/Linux shells). +Write DubStack AI provider settings into your shell profile (macOS/Linux shells). ```bash # write Gemini key @@ -673,6 +743,12 @@ dub ai env --gemini-model "gemini-2.5-pro-preview" # write Gateway model override dub ai env --gateway-model "google/gemini-2.5-pro" +# write Bedrock profile + region + model +dub ai env \ + --bedrock-profile "bw-sso" \ + --bedrock-region "us-west-2" \ + --bedrock-model "us.anthropic.claude-sonnet-4-6" + # write both dub ai env --gemini-key "" --gateway-key "" @@ -687,6 +763,8 @@ Supported automatic profile detection: - `zsh` → `~/.zshrc` - `bash` → `~/.bashrc` (or `~/.bash_profile` fallback) +After writing exports, DubStack prints the exact activation command to run in your shell so the new values take effect immediately in your terminal session. + ### `dub history` Inspect recent Dub command history used for troubleshooting context. diff --git a/apps/docs/content/docs/commands/create.mdx b/apps/docs/content/docs/commands/create.mdx index f0678b7..3731beb 100644 --- a/apps/docs/content/docs/commands/create.mdx +++ b/apps/docs/content/docs/commands/create.mdx @@ -44,7 +44,7 @@ dub create -ai ## AI Mode -When using `--ai` or `-ai`, DubStack analyzes your staged changes and generates a descriptive branch name and conventional commit message. This requires an AI key configured via `dub ai env`. +When using `--ai` or `-ai`, DubStack analyzes your staged changes and generates a descriptive branch name and conventional commit message. Configure the provider with `dub ai setup` or `dub ai env`, then enable the assistant with `dub config ai-assistant on`. If your repository enables AI create defaults, `--no-ai` lets you force manual mode for a single invocation. diff --git a/apps/docs/content/docs/commands/docs.mdx b/apps/docs/content/docs/commands/docs.mdx new file mode 100644 index 0000000..c8d5bda --- /dev/null +++ b/apps/docs/content/docs/commands/docs.mdx @@ -0,0 +1,12 @@ +--- +title: dub docs +description: Open the DubStack docs site in your browser. +--- + +## Usage + +```bash +dub docs +``` + +`dub docs` opens the canonical DubStack documentation site in your default browser. diff --git a/apps/docs/content/docs/commands/meta.json b/apps/docs/content/docs/commands/meta.json index 6ef1db2..eb877bb 100644 --- a/apps/docs/content/docs/commands/meta.json +++ b/apps/docs/content/docs/commands/meta.json @@ -1,6 +1,8 @@ { "title": "Commands", "pages": [ + "docs", + "repo", "create", "modify", "checkout", diff --git a/apps/docs/content/docs/commands/repo.mdx b/apps/docs/content/docs/commands/repo.mdx new file mode 100644 index 0000000..0b33365 --- /dev/null +++ b/apps/docs/content/docs/commands/repo.mdx @@ -0,0 +1,14 @@ +--- +title: dub repo +description: Open the current repository GitHub page in your browser. +--- + +## Usage + +```bash +dub repo +``` + +`dub repo` resolves the current branch upstream remote first, falls back to `origin`, normalizes common SSH and HTTPS GitHub remotes, and opens the repository homepage in your default browser. + +If the current repository does not use GitHub remotes, DubStack fails clearly instead of opening the wrong URL. diff --git a/apps/docs/content/docs/commands/submit.mdx b/apps/docs/content/docs/commands/submit.mdx index 033fa6a..6371806 100644 --- a/apps/docs/content/docs/commands/submit.mdx +++ b/apps/docs/content/docs/commands/submit.mdx @@ -40,6 +40,8 @@ dub submit --path stack --fix When AI submit defaults are enabled for the repository, `--no-ai` lets you force a manual PR body update for one run. AI submit only writes the PR description body; the PR title still comes from the last commit message. +Configure the provider with `dub ai setup` or `dub ai env`, then enable the assistant with `dub config ai-assistant on`. + If your repository includes a GitHub pull request template, DubStack asks AI to preserve that template structure when generating the PR description body. Supported locations include `.github/pull_request_template.md`, `.github/PULL_REQUEST_TEMPLATE.md`, `.github/PULL_REQUEST_TEMPLATE/*.md`, `docs/pull_request_template.md`, and `pull_request_template.md`. ```bash diff --git a/apps/docs/content/docs/getting-started/quickstart.mdx b/apps/docs/content/docs/getting-started/quickstart.mdx index 127bae7..b30c17a 100644 --- a/apps/docs/content/docs/getting-started/quickstart.mdx +++ b/apps/docs/content/docs/getting-started/quickstart.mdx @@ -25,17 +25,18 @@ Get from zero to a working stacked PR flow fast. ## Optional: Enable AI Assistant ```bash -# Add one API key to your shell profile -dub ai env --gemini-key "" -# or: -dub ai env --gateway-key "" +# Guided setup for Gemini, AI Gateway, or Amazon Bedrock +dub ai setup -# Reload your shell +# Reload your shell with the command DubStack prints source ~/.zshrc # Enable assistant for this repo dub config ai-assistant on +# Pin the provider for this repository +dub config ai-provider bedrock + # Optional: enable AI defaults dub config ai-defaults create on dub config ai-defaults submit on @@ -45,6 +46,17 @@ dub config ai-defaults flow on dub ai ask "Summarize this stack from trunk to current branch" ``` +For Bedrock teams using AWS SSO or role-based auth, DubStack supports: + +```bash +dub ai env \ + --bedrock-profile "bw-sso" \ + --bedrock-region "us-west-2" \ + --bedrock-model "us.anthropic.claude-sonnet-4-6" +``` + +`dub ai setup` and `dub ai env` both print the exact activation command to run after updating your shell profile. + If you want DubStack AI to follow your repository's formatting, also set up: - a PR template in `.github/pull_request_template.md` or `.github/PULL_REQUEST_TEMPLATE/*.md` diff --git a/apps/docs/content/docs/guides/ai-assistant.mdx b/apps/docs/content/docs/guides/ai-assistant.mdx index e6752bd..e63f2a6 100644 --- a/apps/docs/content/docs/guides/ai-assistant.mdx +++ b/apps/docs/content/docs/guides/ai-assistant.mdx @@ -1,26 +1,31 @@ --- title: AI Commands -description: Complete guide to DubStack AI setup, shortcuts, metadata generation, flow, templates, and conflict resolution. +description: Complete guide to DubStack AI setup, provider selection, metadata generation, flow, templates, and conflict resolution. --- DubStack includes an AI assistant for repository-aware questions, AI-generated branch and commit metadata, AI-generated PR descriptions, and conflict-resolution help. -## Setup +## Quick Setup -```bash -# Add one API key to your shell profile -dub ai env --gemini-key "" -# or: -dub ai env --gateway-key "" +The easiest setup path is the guided wizard: -# Reload your shell +```bash +dub ai setup source ~/.zshrc - -# Enable AI for this repository dub config ai-assistant on ``` -You can also set repo-local defaults for AI-assisted authoring: +You can then pin the provider for the current repository: + +```bash +dub config ai-provider gemini +# or: +dub config ai-provider gateway +# or: +dub config ai-provider bedrock +``` + +Optional repo-local AI defaults: ```bash dub config ai-defaults create on @@ -28,6 +33,95 @@ dub config ai-defaults submit on dub config ai-defaults flow on ``` +## Providers + +### Gemini + +- Auth: `DUBSTACK_GEMINI_API_KEY` +- Optional global model default: `DUBSTACK_GEMINI_MODEL` +- Good fit when you want direct Google access from DubStack + +### AI Gateway + +- Auth: `DUBSTACK_AI_GATEWAY_API_KEY` +- Optional global model default: `DUBSTACK_AI_GATEWAY_MODEL` +- Good fit when your team standardizes on Vercel AI Gateway + +### Amazon Bedrock + +- Required: `DUBSTACK_BEDROCK_AWS_REGION` +- Required: `DUBSTACK_BEDROCK_MODEL` +- Optional: `DUBSTACK_BEDROCK_AWS_PROFILE` +- Good fit for AWS SSO, instance roles, ECS roles, EKS service accounts, and other AWS credential-chain environments + +Bedrock support in DubStack is intentionally secure-auth only. DubStack does not introduce its own secret-key environment variable flow for AWS credentials. + +## Provider Selection and Precedence + +DubStack resolves AI providers in this order: + +1. Repo-local provider selection from `dub config ai-provider ...` +2. Repo-local provider-specific model override from `dub config ai-model ...` +3. Global provider defaults from shell profile exports +4. Auto fallback order: Gemini, then AI Gateway, then Bedrock + +Examples: + +```bash +# Inspect current provider choice +dub config ai-provider + +# Pin this repo to Bedrock +dub config ai-provider bedrock + +# Set a repo-local Bedrock model override +dub config ai-model "us.anthropic.claude-sonnet-4-6" --provider bedrock + +# Clear the repo-local override and fall back to env defaults +dub config ai-model --provider bedrock --clear +``` + +## `dub ai setup` + +`dub ai setup` is the guided onboarding flow for all supported providers. + +It helps you: + +- choose the repo-local provider +- choose a curated model or enter a custom model ID +- write global provider defaults into your shell profile +- optionally store a repo-local model override + +For Bedrock, the wizard prompts for AWS profile and region instead of API secrets. +When the wizard writes env vars, DubStack loads them into the current `dub` process and prints the exact shell command to run so your terminal session can pick them up immediately. + +## `dub ai env` + +Use `dub ai env` when you want explicit shell-profile edits without the wizard. + +```bash +# Gemini +dub ai env --gemini-key "" +dub ai env --gemini-model "gemini-3-flash-preview" + +# AI Gateway +dub ai env --gateway-key "" +dub ai env --gateway-model "google/gemini-3-flash" + +# Bedrock +dub ai env \ + --bedrock-profile "bw-sso" \ + --bedrock-region "us-west-2" \ + --bedrock-model "us.anthropic.claude-sonnet-4-6" +``` + +Supported automatic profile detection: + +- `zsh` → `~/.zshrc` +- `bash` → `~/.bashrc` (or `~/.bash_profile`) + +After writing exports, DubStack prints the exact activation command to run in your shell so the new values take effect right away. + ## Direct Assistant Usage Ask the AI assistant explicitly: @@ -96,30 +190,6 @@ dub f --dry-run - the commands DubStack will run Use `-y` to auto-approve. Use `--dry-run` to preview without creating or submitting anything. -If you run `dub flow` in a non-interactive terminal, `-y` is required because approval prompts need a TTY. - -`dub ai ask` streams response text as it arrives and renders tool/status lines separately when the output stream is a TTY. - -## Defaults and Precedence - -DubStack resolves AI usage in this order: - -1. command flag such as `--ai` or `--no-ai` -2. repo-local default from `dub config ai-defaults ...` -3. built-in fallback of off - -Examples: - -```bash -# Enable AI by default for create -dub config ai-defaults create on - -# Force manual mode one time -dub create --no-ai feat/manual-branch - -# Enable AI by default for submit PR descriptions -dub config ai-defaults submit on -``` ## Templates @@ -127,7 +197,7 @@ DubStack can use repository templates as the formatting contract for AI-generate ### Pull Request Templates -DubStack checks common GitHub PR template locations, including: +Supported locations include: - `.github/pull_request_template.md` - `.github/PULL_REQUEST_TEMPLATE.md` @@ -135,40 +205,15 @@ DubStack checks common GitHub PR template locations, including: - `docs/pull_request_template.md` - `pull_request_template.md` -When a PR template exists, `dub submit --ai` and `dub flow` ask the AI to preserve that structure instead of inventing a generic PR description. - -Example: - -```bash -mkdir -p .github -cat <<'EOF' > .github/pull_request_template.md -## Summary - -## Testing - -## Checklist -- [ ] Ready for review -EOF -``` - ### Commit Message Templates DubStack also reads the repository commit message template from git config: ```bash -cat <<'EOF' > .gitmessage -feat(scope): summary - -## Testing -- [ ] added coverage -EOF - git config commit.template .gitmessage ``` -When a commit template is configured, `dub create --ai` and `dub flow` preserve that shape in the generated commit message body. The first line still needs to be a valid Conventional Commit subject. - -If no template is present, DubStack falls back to its default AI formatting. +When templates are present, DubStack asks the model to preserve that structure instead of inventing a generic format. ## Conflict Resolution @@ -178,17 +223,12 @@ Use AI to help resolve conflicts: dub ai resolve ``` -If you are already in a continue flow: - -```bash -dub continue --ai -``` - ## Notes - AI requires `dub config ai-assistant on` in the current repository. - If AI is explicitly requested and unavailable, DubStack fails clearly instead of silently downgrading. - PR titles remain commit-derived for squash-merge safety. +- `dub docs` opens the docs site, and `dub repo` opens the current repository GitHub page in your browser. ## Local Evals @@ -199,5 +239,3 @@ pnpm evals pnpm evals:watch pnpm evals:export ``` - -The first suite targets the same pure `generateFlowMetadata(...)` helper used by `dub flow`, combining structural contract checks with an AI judge scorer. diff --git a/packages/cli/evals/dub-flow-metadata.eval.ts b/packages/cli/evals/dub-flow-metadata.eval.ts index c7dda2d..3d5fc24 100644 --- a/packages/cli/evals/dub-flow-metadata.eval.ts +++ b/packages/cli/evals/dub-flow-metadata.eval.ts @@ -6,6 +6,7 @@ import { type AiMetadataDependencies, generateFlowMetadata, } from '../src/lib/ai-metadata'; +import type { DubConfig } from '../src/lib/config'; interface FlowEvalInput { name: string; @@ -33,6 +34,17 @@ interface FlowEvalOutput { prDescription: string; } +function createProviderConfig(): DubConfig['ai']['provider'] { + return { + selected: 'auto', + models: { + gemini: null, + gateway: null, + bedrock: null, + }, + }; +} + const LARGE_MIXED_MONOREPO_DIFF = `diff --git a/.agents/skills/beads/SKILL.md b/.agents/skills/beads/SKILL.md new file mode 100644 index 0000000..1111111 @@ -343,6 +355,7 @@ evalite('dub flow metadata generation', { commitTemplate: input.commitTemplate, prTemplate: input.prTemplate, }, + createProviderConfig(), ); }, scorers: [ diff --git a/packages/cli/package.json b/packages/cli/package.json index b878b1d..87a1c00 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -35,8 +35,13 @@ ], "license": "MIT", "dependencies": { + "@ai-sdk/amazon-bedrock": "^4.0.77", "@ai-sdk/google": "^3.0.30", + "@aws-sdk/credential-providers": "^3.1004.0", + "@inquirer/input": "^5.0.8", + "@inquirer/password": "^5.0.8", "@inquirer/search": "^4.1.3", + "@inquirer/select": "^5.1.0", "ai": "^6.0.97", "bash-tool": "^1.3.15", "chalk": "^5.6.2", diff --git a/packages/cli/src/commands/ai-env.test.ts b/packages/cli/src/commands/ai-env.test.ts index 6ca7620..d13a221 100644 --- a/packages/cli/src/commands/ai-env.test.ts +++ b/packages/cli/src/commands/ai-env.test.ts @@ -5,12 +5,15 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { configureAiEnv } from './ai-env'; let tempDir: string; +let envSnapshot: NodeJS.ProcessEnv; beforeEach(async () => { + envSnapshot = { ...process.env }; tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'dub-ai-env-')); }); afterEach(async () => { + process.env = envSnapshot; await fs.promises.rm(tempDir, { recursive: true, force: true }); }); @@ -27,6 +30,8 @@ describe('configureAiEnv', () => { expect(result.profilePath).toBe(profile); expect(result.updated).toEqual(['DUBSTACK_GEMINI_API_KEY']); + expect(result.activationCommand).toBe(`source '${profile}'`); + expect(process.env.DUBSTACK_GEMINI_API_KEY).toBe('gemini-secret'); expect(updated).toContain("export DUBSTACK_GEMINI_API_KEY='gemini-secret'"); }); @@ -106,6 +111,42 @@ describe('configureAiEnv', () => { ); }); + it('writes Bedrock profile, region, and model exports', async () => { + const profile = path.join(tempDir, '.zshrc'); + await fs.promises.writeFile(profile, '# existing\n'); + + const result = await configureAiEnv({ + bedrockProfile: 'bw-sso', + bedrockRegion: 'us-west-2', + bedrockModel: 'us.anthropic.claude-sonnet-4-6', + profile, + }); + const updated = await fs.promises.readFile(profile, 'utf8'); + + expect(result.updated).toEqual([ + 'DUBSTACK_BEDROCK_AWS_PROFILE', + 'DUBSTACK_BEDROCK_AWS_REGION', + 'DUBSTACK_BEDROCK_MODEL', + ]); + expect(updated).toContain("export DUBSTACK_BEDROCK_AWS_PROFILE='bw-sso'"); + expect(updated).toContain("export DUBSTACK_BEDROCK_AWS_REGION='us-west-2'"); + expect(updated).toContain( + "export DUBSTACK_BEDROCK_MODEL='us.anthropic.claude-sonnet-4-6'", + ); + expect(process.env.DUBSTACK_BEDROCK_AWS_PROFILE).toBe('bw-sso'); + expect(process.env.DUBSTACK_BEDROCK_AWS_REGION).toBe('us-west-2'); + expect(process.env.DUBSTACK_BEDROCK_MODEL).toBe( + 'us.anthropic.claude-sonnet-4-6', + ); + }); + + it('rejects empty Bedrock region values', async () => { + const profile = path.join(tempDir, '.zshrc'); + await expect( + configureAiEnv({ profile, bedrockRegion: ' ' }), + ).rejects.toThrow('Bedrock region cannot be empty'); + }); + it('rejects empty model values', async () => { const profile = path.join(tempDir, '.zshrc'); await expect( @@ -126,7 +167,7 @@ describe('configureAiEnv', () => { it('throws when no key or model is provided', async () => { const profile = path.join(tempDir, '.zshrc'); await expect(configureAiEnv({ profile })).rejects.toThrow( - 'Provide at least one key or model', + 'Provide at least one key, model, or Bedrock setting', ); }); }); diff --git a/packages/cli/src/commands/ai-env.ts b/packages/cli/src/commands/ai-env.ts index 775b39c..b3fa562 100644 --- a/packages/cli/src/commands/ai-env.ts +++ b/packages/cli/src/commands/ai-env.ts @@ -7,19 +7,26 @@ const GEMINI_KEY_NAME = 'DUBSTACK_GEMINI_API_KEY'; const GATEWAY_KEY_NAME = 'DUBSTACK_AI_GATEWAY_API_KEY'; const GEMINI_MODEL_NAME = 'DUBSTACK_GEMINI_MODEL'; const GATEWAY_MODEL_NAME = 'DUBSTACK_AI_GATEWAY_MODEL'; +const BEDROCK_PROFILE_NAME = 'DUBSTACK_BEDROCK_AWS_PROFILE'; +const BEDROCK_REGION_NAME = 'DUBSTACK_BEDROCK_AWS_REGION'; +const BEDROCK_MODEL_NAME = 'DUBSTACK_BEDROCK_MODEL'; -interface ConfigureAiEnvOptions { +export interface ConfigureAiEnvOptions { geminiKey?: string; gatewayKey?: string; geminiModel?: string; gatewayModel?: string; + bedrockProfile?: string; + bedrockRegion?: string; + bedrockModel?: string; shell?: string; profile?: string; } -interface ConfigureAiEnvResult { +export interface ConfigureAiEnvResult { profilePath: string; updated: string[]; + activationCommand: string; } export async function configureAiEnv( @@ -29,10 +36,13 @@ export async function configureAiEnv( !options.geminiKey && !options.gatewayKey && !options.geminiModel && - !options.gatewayModel + !options.gatewayModel && + !options.bedrockProfile && + !options.bedrockRegion && + !options.bedrockModel ) { throw new DubError( - 'Provide at least one key or model via --gemini-key, --gateway-key, --gemini-model, or --gateway-model.', + 'Provide at least one key, model, or Bedrock setting via --gemini-key, --gateway-key, --gemini-model, --gateway-model, --bedrock-profile, --bedrock-region, or --bedrock-model.', ); } @@ -46,27 +56,53 @@ export async function configureAiEnv( ? fs.readFileSync(profilePath, 'utf-8') : ''; const updated: string[] = []; + const appliedValues: Record = {}; if (options.geminiKey) { content = upsertExport(content, GEMINI_KEY_NAME, options.geminiKey); updated.push(GEMINI_KEY_NAME); + appliedValues[GEMINI_KEY_NAME] = options.geminiKey; } if (options.gatewayKey) { content = upsertExport(content, GATEWAY_KEY_NAME, options.gatewayKey); updated.push(GATEWAY_KEY_NAME); + appliedValues[GATEWAY_KEY_NAME] = options.gatewayKey; } if (options.geminiModel !== undefined) { const model = normalizeGeminiModel(options.geminiModel); content = upsertExport(content, GEMINI_MODEL_NAME, model); updated.push(GEMINI_MODEL_NAME); + appliedValues[GEMINI_MODEL_NAME] = model; } if (options.gatewayModel !== undefined) { const model = normalizeGatewayModel(options.gatewayModel); content = upsertExport(content, GATEWAY_MODEL_NAME, model); updated.push(GATEWAY_MODEL_NAME); + appliedValues[GATEWAY_MODEL_NAME] = model; + } + + if (options.bedrockProfile !== undefined) { + const profile = normalizeBedrockProfile(options.bedrockProfile); + content = upsertExport(content, BEDROCK_PROFILE_NAME, profile); + updated.push(BEDROCK_PROFILE_NAME); + appliedValues[BEDROCK_PROFILE_NAME] = profile; + } + + if (options.bedrockRegion !== undefined) { + const region = normalizeBedrockRegion(options.bedrockRegion); + content = upsertExport(content, BEDROCK_REGION_NAME, region); + updated.push(BEDROCK_REGION_NAME); + appliedValues[BEDROCK_REGION_NAME] = region; + } + + if (options.bedrockModel !== undefined) { + const model = normalizeBedrockModel(options.bedrockModel); + content = upsertExport(content, BEDROCK_MODEL_NAME, model); + updated.push(BEDROCK_MODEL_NAME); + appliedValues[BEDROCK_MODEL_NAME] = model; } if (!content.endsWith('\n')) { @@ -74,7 +110,13 @@ export async function configureAiEnv( } fs.writeFileSync(profilePath, content); - return { profilePath, updated }; + applyEnvToCurrentProcess(appliedValues); + + return { + profilePath, + updated, + activationCommand: buildActivationCommand(profilePath, options.shell), + }; } function resolveProfilePath(shellOverride?: string): string { @@ -119,6 +161,59 @@ function quoteForShell(value: string): string { return `'${value.replaceAll("'", "'\"'\"'")}'`; } +function applyEnvToCurrentProcess(values: Record): void { + for (const [key, value] of Object.entries(values)) { + process.env[key] = value; + } +} + +function buildActivationCommand( + profilePath: string, + shellOverride?: string, +): string { + const shellKind = detectShellKind(shellOverride, profilePath); + const command = shellKind === 'sh' ? '.' : 'source'; + return `${command} ${quoteForShell(profilePath)}`; +} + +function detectShellKind( + shellOverride: string | undefined, + profilePath: string, +): 'zsh' | 'bash' | 'sh' { + const shellName = + shellOverride?.split('/').pop() ?? + inferShellNameFromProfile(profilePath) ?? + process.env.SHELL?.split('/').pop() ?? + 'sh'; + + if (shellName === 'zsh') { + return 'zsh'; + } + + if (shellName === 'bash') { + return 'bash'; + } + + return 'sh'; +} + +function inferShellNameFromProfile(profilePath: string): string | null { + const profileName = path.basename(profilePath); + if (profileName === '.zshrc') { + return 'zsh'; + } + + if (profileName === '.bashrc' || profileName === '.bash_profile') { + return 'bash'; + } + + if (profileName === '.profile') { + return 'sh'; + } + + return null; +} + function normalizeGeminiModel(value: string): string { const model = value.trim(); if (model.length === 0) { @@ -139,3 +234,27 @@ function normalizeGatewayModel(value: string): string { } return model; } + +function normalizeBedrockProfile(value: string): string { + const profile = value.trim(); + if (profile.length === 0) { + throw new DubError('Bedrock profile cannot be empty.'); + } + return profile; +} + +function normalizeBedrockRegion(value: string): string { + const region = value.trim(); + if (region.length === 0) { + throw new DubError('Bedrock region cannot be empty.'); + } + return region; +} + +function normalizeBedrockModel(value: string): string { + const model = value.trim(); + if (model.length === 0) { + throw new DubError('Bedrock model cannot be empty.'); + } + return model; +} diff --git a/packages/cli/src/commands/ai-resolve.test.ts b/packages/cli/src/commands/ai-resolve.test.ts index f8493e4..938950e 100644 --- a/packages/cli/src/commands/ai-resolve.test.ts +++ b/packages/cli/src/commands/ai-resolve.test.ts @@ -58,6 +58,42 @@ function createMockDeps(overrides?: Partial): AiResolveDeps { streamText: vi.fn().mockReturnValue(aiResponse(defaultResolution)), createGoogleGenerativeAI: vi.fn().mockReturnValue(googleModel), createGateway: vi.fn(), + createAmazonBedrock: vi.fn().mockReturnValue(vi.fn()), + fromIni: vi.fn().mockReturnValue('ini-credentials'), + fromNodeProviderChain: vi.fn().mockReturnValue('default-chain'), + readConfig: vi.fn().mockResolvedValue({ + aiAssistantEnabled: true, + ai: { + defaults: { + createMetadata: false, + submitDescription: false, + flow: false, + }, + provider: { + selected: 'auto', + models: { + gemini: null, + gateway: null, + bedrock: null, + }, + }, + shortcutFallback: { + enabled: true, + typoGuard: 'interactive', + nonTtyPolicy: 'error-with-suggestion', + }, + context: { + shellHistory: { + enabled: true, + maxCommands: 200, + }, + }, + webBrowsing: { + mode: 'model-native', + fallback: 'graceful', + }, + }, + }), gatherConflictContext: vi.fn().mockResolvedValue(createMockContext()), renderBatchPreview: vi.fn(), promptBatchAction: vi.fn().mockResolvedValue('apply-all'), diff --git a/packages/cli/src/commands/ai-resolve.ts b/packages/cli/src/commands/ai-resolve.ts index 01d144b..8530263 100644 --- a/packages/cli/src/commands/ai-resolve.ts +++ b/packages/cli/src/commands/ai-resolve.ts @@ -1,7 +1,10 @@ +import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock'; import { createGoogleGenerativeAI } from '@ai-sdk/google'; -import type { LanguageModel } from 'ai'; +import { fromIni, fromNodeProviderChain } from '@aws-sdk/credential-providers'; import { createGateway, streamText } from 'ai'; import chalk from 'chalk'; +import { buildAiProviderOptions, resolveAiProvider } from '../lib/ai-provider'; +import { readConfig } from '../lib/config'; import type { ConflictContext } from '../lib/conflict-context'; import { gatherConflictContext } from '../lib/conflict-context'; import type { FileResolution } from '../lib/conflict-ui'; @@ -21,6 +24,10 @@ export interface AiResolveDeps { streamText: typeof streamText; createGoogleGenerativeAI: typeof createGoogleGenerativeAI; createGateway: typeof createGateway; + createAmazonBedrock?: typeof createAmazonBedrock; + fromIni?: typeof fromIni; + fromNodeProviderChain?: typeof fromNodeProviderChain; + readConfig: typeof readConfig; gatherConflictContext: typeof gatherConflictContext; renderBatchPreview: typeof renderBatchPreview; promptBatchAction: typeof promptBatchAction; @@ -36,6 +43,10 @@ const DEFAULT_DEPS: AiResolveDeps = { streamText, createGoogleGenerativeAI, createGateway, + createAmazonBedrock, + fromIni, + fromNodeProviderChain, + readConfig, gatherConflictContext, renderBatchPreview, promptBatchAction, @@ -47,15 +58,6 @@ const DEFAULT_DEPS: AiResolveDeps = { abortCommand, }; -const PROVIDER_OPTIONS = { - google: { - thinkingConfig: { - thinkingLevel: 'high' as const, - includeThoughts: true, - }, - }, -} as const; - export async function aiResolve( cwd: string, options: { dryRun?: boolean; abort?: boolean }, @@ -76,7 +78,10 @@ export async function aiResolve( return; } - const context = await deps.gatherConflictContext(cwd); + const [config, context] = await Promise.all([ + deps.readConfig(cwd), + deps.gatherConflictContext(cwd), + ]); if (context.conflictedFiles.length === 0) { throw new DubError('No conflicted files detected.'); @@ -87,8 +92,11 @@ export async function aiResolve( if (!proceed) return; } - const model = resolveModel(deps); - const resolutions = await streamResolutions(context, model, deps); + const resolved = resolveAiProvider({ + deps, + providerConfig: config.ai.provider, + }); + const resolutions = await streamResolutions(context, resolved, deps); deps.validateResolutionPaths(resolutions, context.conflictedFiles, cwd); @@ -98,7 +106,7 @@ export async function aiResolve( return; } - await applyAndContinue(cwd, resolutions, model, deps, 0); + await applyAndContinue(cwd, resolutions, resolved, deps, 0); } finally { process.removeListener('SIGINT', sigintHandler); } @@ -160,7 +168,7 @@ function buildConflictUserPrompt( async function streamResolutions( context: ConflictContext, - model: LanguageModel, + resolved: ReturnType, deps: AiResolveDeps, errorFeedback?: string, ): Promise { @@ -171,10 +179,12 @@ async function streamResolutions( ); const result = deps.streamText({ - model, + model: resolved.model, system: buildConflictSystemPrompt(), prompt: buildConflictUserPrompt(context, errorFeedback), - providerOptions: PROVIDER_OPTIONS as never, + providerOptions: buildAiProviderOptions(resolved, { + withWebBrowsing: false, + }) as never, }); let fullText = ''; @@ -253,7 +263,7 @@ function validateConfidence(value: unknown): 'high' | 'medium' | 'low' { async function applyAndContinue( cwd: string, resolutions: FileResolution[], - model: LanguageModel, + resolved: ReturnType, deps: AiResolveDeps, retryCount: number, ): Promise { @@ -318,7 +328,7 @@ async function applyAndContinue( } const retryResolutions = await streamResolutions( retryContext, - model, + resolved, deps, errMsg, ); @@ -330,7 +340,7 @@ async function applyAndContinue( await applyAndContinue( cwd, retryResolutions, - model, + resolved, deps, retryCount + 1, ); @@ -343,25 +353,3 @@ async function applyAndContinue( } } } - -function resolveModel(deps: AiResolveDeps): LanguageModel { - const geminiApiKey = process.env.DUBSTACK_GEMINI_API_KEY?.trim(); - if (geminiApiKey) { - const geminiModel = - process.env.DUBSTACK_GEMINI_MODEL?.trim() || 'gemini-3-flash-preview'; - const google = deps.createGoogleGenerativeAI({ apiKey: geminiApiKey }); - return google(geminiModel); - } - - const gatewayApiKey = process.env.DUBSTACK_AI_GATEWAY_API_KEY?.trim(); - if (gatewayApiKey) { - const gatewayModel = - process.env.DUBSTACK_AI_GATEWAY_MODEL?.trim() || 'google/gemini-3-flash'; - const gateway = deps.createGateway({ apiKey: gatewayApiKey }); - return gateway(gatewayModel); - } - - throw new DubError( - "AI requires DUBSTACK_GEMINI_API_KEY or DUBSTACK_AI_GATEWAY_API_KEY. Run 'dub ai env' to configure.", - ); -} diff --git a/packages/cli/src/commands/ai-setup.test.ts b/packages/cli/src/commands/ai-setup.test.ts new file mode 100644 index 0000000..a53b108 --- /dev/null +++ b/packages/cli/src/commands/ai-setup.test.ts @@ -0,0 +1,133 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { aiSetup } from './ai-setup'; + +function createDeps() { + return { + selectProvider: vi.fn(), + selectModel: vi.fn(), + inputCustomModel: vi.fn(), + selectModelScope: vi.fn(), + inputGeminiKey: vi.fn(), + inputGatewayKey: vi.fn(), + inputBedrockProfile: vi.fn(), + inputBedrockRegion: vi.fn(), + configureAiEnv: vi.fn().mockResolvedValue({ + profilePath: '/tmp/.zshrc', + updated: [], + activationCommand: "source '/tmp/.zshrc'", + }), + configAiProvider: vi.fn().mockResolvedValue({ + provider: 'auto', + changed: true, + }), + configAiModel: vi.fn().mockResolvedValue({ + model: null, + changed: true, + }), + }; +} + +describe('aiSetup', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('writes Bedrock env defaults and selects the provider for the repo', async () => { + const deps = createDeps(); + deps.selectProvider.mockResolvedValue('bedrock'); + deps.selectModel.mockResolvedValue('us.anthropic.claude-sonnet-4-6'); + deps.selectModelScope.mockResolvedValue('global'); + deps.inputBedrockProfile.mockResolvedValue('bw-sso'); + deps.inputBedrockRegion.mockResolvedValue('us-west-2'); + + const result = await aiSetup('/repo', deps); + + expect(deps.configureAiEnv).toHaveBeenCalledWith({ + bedrockProfile: 'bw-sso', + bedrockRegion: 'us-west-2', + bedrockModel: 'us.anthropic.claude-sonnet-4-6', + }); + expect(deps.configAiProvider).toHaveBeenCalledWith('/repo', 'bedrock'); + expect(deps.configAiModel).toHaveBeenCalledWith( + '/repo', + 'bedrock', + undefined, + { + clear: true, + }, + ); + expect(result.provider).toBe('bedrock'); + expect(result.model).toBe('us.anthropic.claude-sonnet-4-6'); + expect(result.modelScope).toBe('global'); + expect(result.profilePath).toBe('/tmp/.zshrc'); + expect(result.activationCommand).toBe("source '/tmp/.zshrc'"); + }); + + it('stores a repo-only custom model override for Gemini', async () => { + const deps = createDeps(); + deps.selectProvider.mockResolvedValue('gemini'); + deps.selectModel.mockResolvedValue('__custom__'); + deps.inputCustomModel.mockResolvedValue('gemini-2.5-pro-preview'); + deps.selectModelScope.mockResolvedValue('repo'); + deps.inputGeminiKey.mockResolvedValue('gem-key'); + + const result = await aiSetup('/repo', deps); + + expect(deps.configureAiEnv).toHaveBeenCalledWith({ + geminiKey: 'gem-key', + }); + expect(deps.configAiProvider).toHaveBeenCalledWith('/repo', 'gemini'); + expect(deps.configAiModel).toHaveBeenCalledWith( + '/repo', + 'gemini', + 'gemini-2.5-pro-preview', + ); + expect(result.modelScope).toBe('repo'); + }); + + it('supports repo-only setup without shell profile edits', async () => { + const deps = createDeps(); + deps.selectProvider.mockResolvedValue('gateway'); + deps.selectModel.mockResolvedValue('google/gemini-3-flash'); + deps.selectModelScope.mockResolvedValue('repo'); + deps.inputGatewayKey.mockResolvedValue(undefined); + + const result = await aiSetup('/repo', deps); + + expect(deps.configureAiEnv).not.toHaveBeenCalled(); + expect(deps.configAiProvider).toHaveBeenCalledWith('/repo', 'gateway'); + expect(deps.configAiModel).toHaveBeenCalledWith( + '/repo', + 'gateway', + 'google/gemini-3-flash', + ); + expect(result.updatedEnv).toEqual([]); + expect(result.profilePath).toBeUndefined(); + expect(result.activationCommand).toBeUndefined(); + }); + + it('clears an existing repo override when switching back to global scope', async () => { + const deps = createDeps(); + deps.selectProvider.mockResolvedValue('bedrock'); + deps.selectModel.mockResolvedValue('us.anthropic.claude-sonnet-4-6'); + deps.selectModelScope.mockResolvedValue('global'); + deps.inputBedrockProfile.mockResolvedValue(undefined); + deps.inputBedrockRegion.mockResolvedValue('us-west-2'); + + await aiSetup('/repo', deps); + + expect(deps.configureAiEnv).toHaveBeenCalledWith({ + bedrockProfile: undefined, + bedrockRegion: 'us-west-2', + bedrockModel: 'us.anthropic.claude-sonnet-4-6', + }); + expect(deps.configAiModel).toHaveBeenCalledWith( + '/repo', + 'bedrock', + undefined, + { + clear: true, + }, + ); + }); +}); diff --git a/packages/cli/src/commands/ai-setup.ts b/packages/cli/src/commands/ai-setup.ts new file mode 100644 index 0000000..6db665c --- /dev/null +++ b/packages/cli/src/commands/ai-setup.ts @@ -0,0 +1,226 @@ +import input from '@inquirer/input'; +import password from '@inquirer/password'; +import select from '@inquirer/select'; +import { + type ConfigureAiEnvOptions, + type ConfigureAiEnvResult, + configureAiEnv, +} from './ai-env'; +import { + type AiModelProvider, + type AiProvider, + configAiModel, + configAiProvider, +} from './config'; + +const CUSTOM_MODEL = '__custom__'; + +type ModelScope = 'global' | 'repo'; + +export interface AiSetupResult { + provider: AiModelProvider; + model: string; + modelScope: ModelScope; + updatedEnv: string[]; + profilePath?: string; + activationCommand?: string; +} + +interface AiSetupDeps { + selectProvider: () => Promise; + selectModel: (provider: AiModelProvider) => Promise; + inputCustomModel: (provider: AiModelProvider) => Promise; + selectModelScope: () => Promise; + inputGeminiKey: () => Promise; + inputGatewayKey: () => Promise; + inputBedrockProfile: () => Promise; + inputBedrockRegion: () => Promise; + configureAiEnv: ( + options: ConfigureAiEnvOptions, + ) => Promise; + configAiProvider: typeof configAiProvider; + configAiModel: typeof configAiModel; +} + +const DEFAULT_DEPS: AiSetupDeps = { + selectProvider: async () => + select({ + message: 'Choose the AI provider for this repository', + choices: [ + { name: 'Gemini', value: 'gemini' }, + { name: 'AI Gateway', value: 'gateway' }, + { name: 'Amazon Bedrock', value: 'bedrock' }, + ], + }), + selectModel: async (provider) => + select({ + message: 'Choose a model', + choices: getModelChoices(provider).map((choice) => ({ + name: choice.label, + value: choice.value, + })), + }), + inputCustomModel: async () => + input({ + message: 'Enter the full model ID', + validate: (value) => + value.trim().length > 0 ? true : 'Model cannot be empty.', + }), + selectModelScope: async () => + select({ + message: 'Where should this model be stored?', + choices: [ + { + name: 'Global default (shell profile export)', + value: 'global', + }, + { + name: 'Current repo override (.git/dubstack/config.json)', + value: 'repo', + }, + ], + }), + inputGeminiKey: async () => optionalSecret('Enter DUBSTACK_GEMINI_API_KEY'), + inputGatewayKey: async () => + optionalSecret('Enter DUBSTACK_AI_GATEWAY_API_KEY'), + inputBedrockProfile: async () => + optionalText('Enter DUBSTACK_BEDROCK_AWS_PROFILE'), + inputBedrockRegion: async () => + optionalText('Enter DUBSTACK_BEDROCK_AWS_REGION'), + configureAiEnv, + configAiProvider, + configAiModel, +}; + +export async function aiSetup( + cwd: string, + deps: Partial = {}, +): Promise { + const resolvedDeps: AiSetupDeps = { + ...DEFAULT_DEPS, + ...deps, + }; + + const provider = await resolvedDeps.selectProvider(); + const selectedModel = await resolvedDeps.selectModel(provider); + const model = + selectedModel === CUSTOM_MODEL + ? (await resolvedDeps.inputCustomModel(provider)).trim() + : selectedModel; + const modelScope = await resolvedDeps.selectModelScope(); + + const envOptions = await buildEnvOptions( + provider, + model, + modelScope, + resolvedDeps, + ); + const envResult = hasEnvUpdates(envOptions) + ? await resolvedDeps.configureAiEnv(envOptions) + : null; + + await resolvedDeps.configAiProvider(cwd, provider); + if (modelScope === 'repo') { + await resolvedDeps.configAiModel(cwd, provider, model); + } else { + await resolvedDeps.configAiModel(cwd, provider, undefined, { + clear: true, + }); + } + + return { + provider, + model, + modelScope, + updatedEnv: envResult?.updated ?? [], + profilePath: envResult?.profilePath, + activationCommand: envResult?.activationCommand, + }; +} + +function hasEnvUpdates(options: ConfigureAiEnvOptions): boolean { + return Object.values(options).some((value) => value !== undefined); +} + +async function buildEnvOptions( + provider: AiModelProvider, + model: string, + modelScope: ModelScope, + deps: AiSetupDeps, +): Promise { + if (provider === 'gemini') { + const geminiKey = await deps.inputGeminiKey(); + return { + geminiKey, + geminiModel: modelScope === 'global' ? model : undefined, + }; + } + + if (provider === 'gateway') { + const gatewayKey = await deps.inputGatewayKey(); + return { + gatewayKey, + gatewayModel: modelScope === 'global' ? model : undefined, + }; + } + + return { + bedrockProfile: await deps.inputBedrockProfile(), + bedrockRegion: await deps.inputBedrockRegion(), + bedrockModel: modelScope === 'global' ? model : undefined, + }; +} + +async function optionalText(message: string): Promise { + const value = await input({ + message: `${message} (leave blank to keep current value)`, + }); + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +async function optionalSecret(message: string): Promise { + const value = await password({ + message: `${message} (leave blank to keep current value)`, + mask: '*', + }); + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function getModelChoices(provider: AiProvider): Array<{ + label: string; + value: string; +}> { + if (provider === 'gemini') { + return [ + { label: 'Gemini 3 Flash Preview', value: 'gemini-3-flash-preview' }, + { label: 'Gemini 2.5 Pro Preview', value: 'gemini-2.5-pro-preview' }, + { label: 'Custom model', value: CUSTOM_MODEL }, + ]; + } + + if (provider === 'gateway') { + return [ + { label: 'Google Gemini 3 Flash', value: 'google/gemini-3-flash' }, + { label: 'Google Gemini 2.5 Pro', value: 'google/gemini-2.5-pro' }, + { label: 'Custom model', value: CUSTOM_MODEL }, + ]; + } + + return [ + { + label: 'Claude Sonnet 4.6 (Bedrock)', + value: 'us.anthropic.claude-sonnet-4-6', + }, + { + label: 'Claude Haiku 4.5 (Bedrock)', + value: 'us.anthropic.claude-haiku-4-5-20251001-v1:0', + }, + { + label: 'Claude Opus 4.6 (Bedrock)', + value: 'us.anthropic.claude-opus-4-6-v1', + }, + { label: 'Custom model', value: CUSTOM_MODEL }, + ]; +} diff --git a/packages/cli/src/commands/ai.test.ts b/packages/cli/src/commands/ai.test.ts index d9ec906..3e9f0c8 100644 --- a/packages/cli/src/commands/ai.test.ts +++ b/packages/cli/src/commands/ai.test.ts @@ -249,6 +249,74 @@ describe('askAi', () => { expect(result.modelId).toBe('google/gemini-2.5-pro'); }); + it('uses Bedrock when selected in repo config', async () => { + await writeConfig( + { + aiAssistantEnabled: true, + ai: { + provider: { + selected: 'bedrock', + models: { + bedrock: 'repo-bedrock-model', + }, + }, + }, + }, + dir, + ); + delete process.env.DUBSTACK_GEMINI_API_KEY; + delete process.env.DUBSTACK_AI_GATEWAY_API_KEY; + process.env.DUBSTACK_BEDROCK_AWS_PROFILE = 'bw-sso'; + process.env.DUBSTACK_BEDROCK_AWS_REGION = 'us-west-2'; + process.env.DUBSTACK_BEDROCK_MODEL = 'env-bedrock-model'; + + const streamText = vi.fn().mockReturnValue({ + fullStream: streamFrom(['bedrock']), + }); + const createGoogleGenerativeAI = vi.fn(); + const createGateway = vi.fn(); + const bedrockModel = vi.fn().mockReturnValue('bedrock-model'); + const createAmazonBedrock = vi.fn().mockReturnValue(bedrockModel); + const fromIni = vi.fn().mockReturnValue('ini-provider'); + const fromNodeProviderChain = vi.fn(); + const collectAiContext = vi.fn().mockResolvedValue(fakeContext); + const { createBashTool } = createBashToolMock(); + const output = createOutputCapture(); + + const result = await askAi('Explain this stack', dir, { + output: output.stream, + deps: { + streamText, + createGoogleGenerativeAI, + createGateway, + createAmazonBedrock, + fromIni, + fromNodeProviderChain, + collectAiContext, + createBashTool, + }, + }); + + expect(createGoogleGenerativeAI).not.toHaveBeenCalled(); + expect(createGateway).not.toHaveBeenCalled(); + expect(fromIni).toHaveBeenCalledWith({ profile: 'bw-sso' }); + expect(fromNodeProviderChain).not.toHaveBeenCalled(); + expect(createAmazonBedrock).toHaveBeenCalledWith({ + region: 'us-west-2', + credentialProvider: 'ini-provider', + }); + expect(bedrockModel).toHaveBeenCalledWith('repo-bedrock-model'); + expect(result.provider).toBe('bedrock'); + expect(result.modelId).toBe('repo-bedrock-model'); + expect(streamText).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'bedrock-model', + providerOptions: {}, + }), + ); + expect(output.writes.join('')).toBe('bedrock\n'); + }); + it('streams text output as chunks arrive while still showing TTY status lines', async () => { await writeConfig({ aiAssistantEnabled: true }, dir); process.env.DUBSTACK_GEMINI_API_KEY = 'gem-key'; @@ -405,11 +473,13 @@ describe('askAi', () => { await writeConfig({ aiAssistantEnabled: true }, dir); delete process.env.DUBSTACK_GEMINI_API_KEY; delete process.env.DUBSTACK_AI_GATEWAY_API_KEY; + delete process.env.DUBSTACK_BEDROCK_AWS_REGION; + delete process.env.DUBSTACK_BEDROCK_MODEL; await expect( askAi('hello', dir, { output: createOutputCapture().stream, }), - ).rejects.toThrow('DUBSTACK_GEMINI_API_KEY or DUBSTACK_AI_GATEWAY_API_KEY'); + ).rejects.toThrow('DUBSTACK_BEDROCK_AWS_REGION + DUBSTACK_BEDROCK_MODEL'); }); }); diff --git a/packages/cli/src/commands/ai.ts b/packages/cli/src/commands/ai.ts index 0e93649..7239ee1 100644 --- a/packages/cli/src/commands/ai.ts +++ b/packages/cli/src/commands/ai.ts @@ -1,5 +1,6 @@ +import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock'; import { createGoogleGenerativeAI } from '@ai-sdk/google'; -import type { LanguageModel } from 'ai'; +import { fromIni, fromNodeProviderChain } from '@aws-sdk/credential-providers'; import { createGateway, stepCountIs, streamText } from 'ai'; import { createBashTool } from 'bash-tool'; import { createLocalBashSandbox } from '../lib/ai-bash-sandbox'; @@ -8,6 +9,7 @@ import { buildAiUserPrompt, collectAiContext, } from '../lib/ai-context'; +import { buildAiProviderOptions, resolveAiProvider } from '../lib/ai-provider'; import { readConfig } from '../lib/config'; import { DubError } from '../lib/errors'; import { createTerminalRenderer } from '../lib/terminal-render'; @@ -22,6 +24,9 @@ interface AskAiDependencies { createBashTool: typeof createBashTool; createGoogleGenerativeAI: typeof createGoogleGenerativeAI; createGateway: typeof createGateway; + createAmazonBedrock?: typeof createAmazonBedrock; + fromIni?: typeof fromIni; + fromNodeProviderChain?: typeof fromNodeProviderChain; collectAiContext: typeof collectAiContext; } @@ -31,7 +36,7 @@ interface AskAiOptions { } interface AskAiResult { - provider: 'google' | 'gateway'; + provider: 'google' | 'gateway' | 'bedrock'; modelId: string; webBrowsingRequested: boolean; webBrowsingUsed: boolean; @@ -42,18 +47,12 @@ const DEFAULT_DEPS: AskAiDependencies = { createBashTool, createGoogleGenerativeAI, createGateway, + createAmazonBedrock, + fromIni, + fromNodeProviderChain, collectAiContext, }; -const THINKING_PROVIDER_OPTIONS = { - google: { - thinkingConfig: { - thinkingLevel: 'high' as const, - includeThoughts: true, - }, - }, -} as const; - export async function askAi( prompt: string, cwd: string, @@ -73,7 +72,10 @@ export async function askAi( const output = options.output ?? process.stdout; const deps = options.deps ?? DEFAULT_DEPS; - const resolved = resolveModel(deps); + const resolved = resolveAiProvider({ + deps, + providerConfig: config.ai.provider, + }); const context = await deps.collectAiContext(cwd); const contextPrompt = buildAiUserPrompt(normalizedPrompt, context); const bashToolkit = await deps.createBashTool({ @@ -94,7 +96,9 @@ export async function askAi( tools: { bash: bashToolkit.tools.bash, }, - providerOptions: buildProviderOptions({ withWebBrowsing }) as never, + providerOptions: buildAiProviderOptions(resolved, { + withWebBrowsing, + }) as never, }); return renderStream(result, output); }; @@ -123,18 +127,6 @@ export async function askAi( }; } -function buildProviderOptions(options: { - withWebBrowsing: boolean; -}): Record { - const googleOptions: Record = { - ...(THINKING_PROVIDER_OPTIONS.google as unknown as Record), - }; - if (options.withWebBrowsing) { - googleOptions.useSearchGrounding = true; - } - return { google: googleOptions }; -} - async function renderStream( result: { fullStream: AsyncIterable<{ @@ -235,37 +227,3 @@ function isBrowsingUnsupportedError(error: unknown): boolean { (normalized.includes('grounding') || normalized.includes('brows')) ); } - -function resolveModel(deps: AskAiDependencies): { - provider: 'google' | 'gateway'; - model: LanguageModel; - modelId: string; -} { - const geminiApiKey = process.env.DUBSTACK_GEMINI_API_KEY?.trim(); - if (geminiApiKey) { - const geminiModel = - process.env.DUBSTACK_GEMINI_MODEL?.trim() || 'gemini-3-flash-preview'; - const google = deps.createGoogleGenerativeAI({ apiKey: geminiApiKey }); - return { - provider: 'google', - model: google(geminiModel), - modelId: geminiModel, - }; - } - - const gatewayApiKey = process.env.DUBSTACK_AI_GATEWAY_API_KEY?.trim(); - if (gatewayApiKey) { - const gatewayModel = - process.env.DUBSTACK_AI_GATEWAY_MODEL?.trim() || 'google/gemini-3-flash'; - const gateway = deps.createGateway({ apiKey: gatewayApiKey }); - return { - provider: 'gateway', - model: gateway(gatewayModel), - modelId: gatewayModel, - }; - } - - throw new DubError( - "AI assistant requires DUBSTACK_GEMINI_API_KEY or DUBSTACK_AI_GATEWAY_API_KEY. Run 'dub ai env --gemini-key ' or 'dub ai env --gateway-key '.", - ); -} diff --git a/packages/cli/src/commands/config.test.ts b/packages/cli/src/commands/config.test.ts index 4427ac8..2415f68 100644 --- a/packages/cli/src/commands/config.test.ts +++ b/packages/cli/src/commands/config.test.ts @@ -1,7 +1,12 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { createTestRepo } from '../../test/helpers'; import { readConfig } from '../lib/config'; -import { configAiAssistant, configAiDefaults } from './config'; +import { + configAiAssistant, + configAiDefaults, + configAiModel, + configAiProvider, +} from './config'; let dir: string; let cleanup: () => Promise; @@ -82,3 +87,68 @@ describe('config ai-defaults', () => { ); }); }); + +describe('config ai-provider', () => { + it('returns current provider when selection is omitted', async () => { + const result = await configAiProvider(dir); + + expect(result).toEqual({ provider: 'auto', changed: false }); + }); + + it('writes selected provider when set', async () => { + const result = await configAiProvider(dir, 'bedrock'); + const config = await readConfig(dir); + + expect(result).toEqual({ provider: 'bedrock', changed: true }); + expect(config.ai.provider.selected).toBe('bedrock'); + }); + + it('throws for invalid provider names', async () => { + await expect(configAiProvider(dir, 'claude')).rejects.toThrow( + "AI provider must be one of 'auto', 'gemini', 'gateway', or 'bedrock'.", + ); + }); +}); + +describe('config ai-model', () => { + it('returns current model override when model is omitted', async () => { + const result = await configAiModel(dir, 'bedrock'); + + expect(result).toEqual({ model: null, changed: false }); + }); + + it('writes a provider-specific model override', async () => { + const result = await configAiModel( + dir, + 'bedrock', + 'us.anthropic.claude-sonnet-4-6', + ); + const config = await readConfig(dir); + + expect(result).toEqual({ + model: 'us.anthropic.claude-sonnet-4-6', + changed: true, + }); + expect(config.ai.provider.models.bedrock).toBe( + 'us.anthropic.claude-sonnet-4-6', + ); + }); + + it('clears a provider-specific model override', async () => { + await configAiModel(dir, 'gateway', 'google/gemini-3-flash'); + + const result = await configAiModel(dir, 'gateway', undefined, { + clear: true, + }); + const config = await readConfig(dir); + + expect(result).toEqual({ model: null, changed: true }); + expect(config.ai.provider.models.gateway).toBeNull(); + }); + + it('throws when setting an empty model override', async () => { + await expect(configAiModel(dir, 'gemini', ' ')).rejects.toThrow( + 'Model override cannot be empty.', + ); + }); +}); diff --git a/packages/cli/src/commands/config.ts b/packages/cli/src/commands/config.ts index bf6f348..0aaa7ae 100644 --- a/packages/cli/src/commands/config.ts +++ b/packages/cli/src/commands/config.ts @@ -7,7 +7,19 @@ export interface ConfigBooleanResult { changed: boolean; } +export interface ConfigProviderResult { + provider: 'auto' | 'gemini' | 'gateway' | 'bedrock'; + changed: boolean; +} + +export interface ConfigModelResult { + model: string | null; + changed: boolean; +} + export type AiDefaultTarget = 'create' | 'submit' | 'flow'; +export type AiProvider = 'auto' | 'gemini' | 'gateway' | 'bedrock'; +export type AiModelProvider = Exclude; export async function configAiAssistant( cwd: string, @@ -78,6 +90,86 @@ export async function configAiDefaults( }; } +export async function configAiProvider( + cwd: string, + provider?: string, +): Promise { + const config = await readConfig(cwd); + if (provider == null) { + return { + provider: config.ai.provider.selected, + changed: false, + }; + } + + const parsed = parseAiProvider(provider); + const changed = config.ai.provider.selected !== parsed; + if (changed) { + await writeConfig( + { + ...config, + ai: { + ...config.ai, + provider: { + ...config.ai.provider, + selected: parsed, + }, + }, + }, + cwd, + ); + } + + return { + provider: parsed, + changed, + }; +} + +export async function configAiModel( + cwd: string, + provider: string, + model?: string, + options: { clear?: boolean } = {}, +): Promise { + const config = await readConfig(cwd); + const parsedProvider = parseAiModelProvider(provider); + const current = config.ai.provider.models[parsedProvider]; + + if (!options.clear && model == null) { + return { + model: current, + changed: false, + }; + } + + const next = options.clear ? null : normalizeModelOverride(model); + const changed = current !== next; + if (changed) { + await writeConfig( + { + ...config, + ai: { + ...config.ai, + provider: { + ...config.ai.provider, + models: { + ...config.ai.provider.models, + [parsedProvider]: next, + }, + }, + }, + }, + cwd, + ); + } + + return { + model: next, + changed, + }; +} + function parseAiAssistantState(value: string): boolean { if (value === 'on') return true; if (value === 'off') return false; @@ -94,3 +186,34 @@ function resolveAiDefaultKey( "Config target must be one of 'create', 'submit', or 'flow'.", ); } + +function parseAiProvider(value: string): AiProvider { + if ( + value === 'auto' || + value === 'gemini' || + value === 'gateway' || + value === 'bedrock' + ) { + return value; + } + throw new DubError( + "AI provider must be one of 'auto', 'gemini', 'gateway', or 'bedrock'.", + ); +} + +function parseAiModelProvider(value: string): AiModelProvider { + if (value === 'gemini' || value === 'gateway' || value === 'bedrock') { + return value; + } + throw new DubError( + "AI model provider must be one of 'gemini', 'gateway', or 'bedrock'.", + ); +} + +function normalizeModelOverride(value: string | undefined): string { + const model = value?.trim() ?? ''; + if (model.length === 0) { + throw new DubError('Model override cannot be empty.'); + } + return model; +} diff --git a/packages/cli/src/commands/create.ts b/packages/cli/src/commands/create.ts index 10574b9..a02173b 100644 --- a/packages/cli/src/commands/create.ts +++ b/packages/cli/src/commands/create.ts @@ -1,4 +1,6 @@ +import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock'; import { createGoogleGenerativeAI } from '@ai-sdk/google'; +import { fromIni, fromNodeProviderChain } from '@aws-sdk/credential-providers'; import { createGateway, generateText } from 'ai'; import { buildAiDiffContext } from '../lib/ai-diff-context'; import { @@ -49,6 +51,9 @@ const DEFAULT_DEPS: CreateDependencies = { generateText, createGoogleGenerativeAI, createGateway, + createAmazonBedrock, + fromIni, + fromNodeProviderChain, }; /** @@ -162,6 +167,7 @@ export async function create( { commitTemplate: templates.commitTemplate, }, + config.ai.provider, ); branchName = generated.branch; commitMessage = generated.message; diff --git a/packages/cli/src/commands/docs.test.ts b/packages/cli/src/commands/docs.test.ts new file mode 100644 index 0000000..8ef3029 --- /dev/null +++ b/packages/cli/src/commands/docs.test.ts @@ -0,0 +1,23 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../lib/browser.js', () => ({ + openUrl: vi.fn(), +})); + +import { openUrl } from '../lib/browser'; +import { DUBSTACK_DOCS_URL } from '../lib/external-links'; +import { docs } from './docs'; + +const mockOpenUrl = openUrl as ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + mockOpenUrl.mockResolvedValue(undefined); +}); + +describe('docs', () => { + it('opens the shared docs URL constant', async () => { + await docs(); + expect(mockOpenUrl).toHaveBeenCalledWith(DUBSTACK_DOCS_URL); + }); +}); diff --git a/packages/cli/src/commands/docs.ts b/packages/cli/src/commands/docs.ts new file mode 100644 index 0000000..dce635b --- /dev/null +++ b/packages/cli/src/commands/docs.ts @@ -0,0 +1,6 @@ +import { openUrl } from '../lib/browser'; +import { DUBSTACK_DOCS_URL } from '../lib/external-links'; + +export async function docs(): Promise { + await openUrl(DUBSTACK_DOCS_URL); +} diff --git a/packages/cli/src/commands/flow.ts b/packages/cli/src/commands/flow.ts index 2a0ef55..dac1e95 100644 --- a/packages/cli/src/commands/flow.ts +++ b/packages/cli/src/commands/flow.ts @@ -1,7 +1,9 @@ import * as fs from 'node:fs'; import { stdin as input, stdout as output } from 'node:process'; import * as readline from 'node:readline/promises'; +import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock'; import { createGoogleGenerativeAI } from '@ai-sdk/google'; +import { fromIni, fromNodeProviderChain } from '@aws-sdk/credential-providers'; import { createGateway, generateText } from 'ai'; import { execa } from 'execa'; import { buildAiDiffContext } from '../lib/ai-diff-context'; @@ -87,6 +89,9 @@ const DEFAULT_DEPS: FlowDependencies = { generateText, createGoogleGenerativeAI, createGateway, + createAmazonBedrock, + fromIni, + fromNodeProviderChain, generateFlowMetadata, readMetadataTemplates, readConfig, @@ -171,6 +176,7 @@ export async function flow( commitTemplate: templates.commitTemplate, prTemplate: templates.prTemplate, }, + config.ai.provider, ); let commitMessage = generated.commitMessage; let prDescription = generated.prDescription; diff --git a/packages/cli/src/commands/repo.test.ts b/packages/cli/src/commands/repo.test.ts new file mode 100644 index 0000000..a18a6ea --- /dev/null +++ b/packages/cli/src/commands/repo.test.ts @@ -0,0 +1,24 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../lib/github.js', () => ({ + openRepositoryInBrowser: vi.fn(), +})); + +import { openRepositoryInBrowser } from '../lib/github'; +import { repo } from './repo'; + +const mockOpenRepositoryInBrowser = openRepositoryInBrowser as ReturnType< + typeof vi.fn +>; + +beforeEach(() => { + vi.clearAllMocks(); + mockOpenRepositoryInBrowser.mockResolvedValue(undefined); +}); + +describe('repo', () => { + it('opens the current repository web URL in the browser', async () => { + await repo('/repo'); + expect(mockOpenRepositoryInBrowser).toHaveBeenCalledWith('/repo'); + }); +}); diff --git a/packages/cli/src/commands/repo.ts b/packages/cli/src/commands/repo.ts new file mode 100644 index 0000000..70b6449 --- /dev/null +++ b/packages/cli/src/commands/repo.ts @@ -0,0 +1,5 @@ +import { openRepositoryInBrowser } from '../lib/github'; + +export async function repo(cwd: string): Promise { + await openRepositoryInBrowser(cwd); +} diff --git a/packages/cli/src/commands/submit.test.ts b/packages/cli/src/commands/submit.test.ts index 49ee961..45dfe6e 100644 --- a/packages/cli/src/commands/submit.test.ts +++ b/packages/cli/src/commands/submit.test.ts @@ -76,6 +76,45 @@ const mockReadMetadataTemplates = readMetadataTemplates as ReturnType< typeof vi.fn >; +function makeConfig(overrides?: { + aiAssistantEnabled?: boolean; + submitDescription?: boolean; +}) { + return { + aiAssistantEnabled: overrides?.aiAssistantEnabled ?? false, + ai: { + defaults: { + createMetadata: false, + submitDescription: overrides?.submitDescription ?? false, + flow: false, + }, + provider: { + selected: 'auto' as const, + models: { + gemini: null, + gateway: null, + bedrock: null, + }, + }, + shortcutFallback: { + enabled: true, + typoGuard: 'interactive' as const, + nonTtyPolicy: 'error-with-suggestion' as const, + }, + context: { + shellHistory: { + enabled: true, + maxCommands: 200, + }, + }, + webBrowsing: { + mode: 'model-native' as const, + fallback: 'graceful' as const, + }, + }, + }; +} + function makeState( branches: { name: string; @@ -102,14 +141,7 @@ beforeEach(() => { vi.clearAllMocks(); mockEnsureGhInstalled.mockResolvedValue(undefined); mockCheckGhAuth.mockResolvedValue(undefined); - mockReadConfig.mockResolvedValue({ - aiAssistantEnabled: false, - ai: { - defaults: { - submitDescription: false, - }, - }, - }); + mockReadConfig.mockResolvedValue(makeConfig()); mockWriteState.mockResolvedValue(undefined); mockPushBranch.mockResolvedValue(undefined); mockGetBranchTip.mockImplementation( @@ -127,14 +159,9 @@ beforeEach(() => { describe('submit', () => { it('uses the repo default to enable AI PR descriptions when no flag is passed', async () => { - mockReadConfig.mockResolvedValue({ - aiAssistantEnabled: true, - ai: { - defaults: { - submitDescription: true, - }, - }, - }); + mockReadConfig.mockResolvedValue( + makeConfig({ aiAssistantEnabled: true, submitDescription: true }), + ); process.env.DUBSTACK_GEMINI_API_KEY = 'gem-key'; mockGetCurrentBranch.mockResolvedValue('feat/a'); mockReadState.mockResolvedValue( @@ -181,14 +208,9 @@ describe('submit', () => { }); it('allows --no-ai to override an enabled repo default', async () => { - mockReadConfig.mockResolvedValue({ - aiAssistantEnabled: true, - ai: { - defaults: { - submitDescription: true, - }, - }, - }); + mockReadConfig.mockResolvedValue( + makeConfig({ aiAssistantEnabled: true, submitDescription: true }), + ); mockGetCurrentBranch.mockResolvedValue('feat/a'); mockReadState.mockResolvedValue( makeState([ @@ -221,14 +243,7 @@ describe('submit', () => { }); it('preserves user-authored body content and replaces only the ai-managed summary', async () => { - mockReadConfig.mockResolvedValue({ - aiAssistantEnabled: true, - ai: { - defaults: { - submitDescription: false, - }, - }, - }); + mockReadConfig.mockResolvedValue(makeConfig({ aiAssistantEnabled: true })); process.env.DUBSTACK_GEMINI_API_KEY = 'gem-key'; mockGetCurrentBranch.mockResolvedValue('feat/a'); mockReadState.mockResolvedValue( @@ -280,14 +295,7 @@ describe('submit', () => { }); it('uses the branch diff against the parent even for the current branch', async () => { - mockReadConfig.mockResolvedValue({ - aiAssistantEnabled: true, - ai: { - defaults: { - submitDescription: false, - }, - }, - }); + mockReadConfig.mockResolvedValue(makeConfig({ aiAssistantEnabled: true })); process.env.DUBSTACK_GEMINI_API_KEY = 'gem-key'; mockGetCurrentBranch.mockResolvedValue('feat/a'); mockReadState.mockResolvedValue( @@ -325,14 +333,7 @@ describe('submit', () => { }); it('surfaces diff lookup failures when ai descriptions are requested', async () => { - mockReadConfig.mockResolvedValue({ - aiAssistantEnabled: true, - ai: { - defaults: { - submitDescription: false, - }, - }, - }); + mockReadConfig.mockResolvedValue(makeConfig({ aiAssistantEnabled: true })); process.env.DUBSTACK_GEMINI_API_KEY = 'gem-key'; mockGetCurrentBranch.mockResolvedValue('feat/a'); mockReadState.mockResolvedValue( @@ -366,14 +367,7 @@ describe('submit', () => { }); it('creates new PRs with the last commit message as the title even when AI descriptions are enabled', async () => { - mockReadConfig.mockResolvedValue({ - aiAssistantEnabled: true, - ai: { - defaults: { - submitDescription: false, - }, - }, - }); + mockReadConfig.mockResolvedValue(makeConfig({ aiAssistantEnabled: true })); process.env.DUBSTACK_GEMINI_API_KEY = 'gem-key'; mockGetCurrentBranch.mockResolvedValue('feat/a'); mockReadState.mockResolvedValue( diff --git a/packages/cli/src/commands/submit.ts b/packages/cli/src/commands/submit.ts index 06aafd7..495467b 100644 --- a/packages/cli/src/commands/submit.ts +++ b/packages/cli/src/commands/submit.ts @@ -1,4 +1,6 @@ +import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock'; import { createGoogleGenerativeAI } from '@ai-sdk/google'; +import { fromIni, fromNodeProviderChain } from '@aws-sdk/credential-providers'; import { createGateway, generateText } from 'ai'; import { type AiMetadataDependencies, @@ -78,6 +80,9 @@ const DEFAULT_DEPS: SubmitDependencies = { generateText, createGoogleGenerativeAI, createGateway, + createAmazonBedrock, + fromIni, + fromNodeProviderChain, }; /** @@ -179,6 +184,7 @@ export async function submit( deps, summaryOverrides: options.summaryOverrides, prTemplate: templates?.prTemplate ?? null, + providerConfig: config.ai.provider, }); for (const branch of plan.branches) { @@ -361,6 +367,9 @@ async function updateAllPrBodies( deps: SubmitDependencies; summaryOverrides?: Map; prTemplate: string | null; + providerConfig: NonNullable< + Awaited>['ai'] + >['provider']; }, ): Promise { const tableEntries = new Map(); @@ -413,6 +422,7 @@ async function updateAllPrBodies( { prTemplate: options.prTemplate, }, + options.providerConfig, ) : ''; const finalBody = composePrBody( diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 0dda8bc..d7e8400 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -32,6 +32,7 @@ import { children } from './commands/children'; import { continueCommand } from './commands/continue'; import { create } from './commands/create'; import { deleteCommand } from './commands/delete'; +import { docs } from './commands/docs'; import { doctor } from './commands/doctor'; import { flow } from './commands/flow'; import { init } from './commands/init'; @@ -44,6 +45,7 @@ import { postMerge } from './commands/post-merge'; import { pr } from './commands/pr'; import { prune } from './commands/prune'; import { ready } from './commands/ready'; +import { repo } from './commands/repo'; import { restack, restackContinue } from './commands/restack'; import type { SubmitPathMode } from './commands/submit'; import { submit } from './commands/submit'; @@ -104,6 +106,32 @@ Examples: } }); +program + .command('docs') + .description('Open the DubStack docs website in your browser') + .addHelpText( + 'after', + ` +Examples: + $ dub docs Open the DubStack docs website`, + ) + .action(async () => { + await docs(); + }); + +program + .command('repo') + .description('Open the current repository GitHub page in your browser') + .addHelpText( + 'after', + ` +Examples: + $ dub repo Open the current repository GitHub page`, + ) + .action(async () => { + await repo(process.cwd()); + }); + program .command('create') .argument('[branch-name]', 'Name of the new branch to create') @@ -998,6 +1026,93 @@ program ); } }), + ) + .addCommand( + new Command('ai-provider') + .argument( + '[provider]', + 'Set to auto/gemini/gateway/bedrock (omit to inspect current value)', + ) + .description('Manage the repo-local AI provider selection') + .action(async (provider?: string) => { + const { configAiProvider } = await import('./commands/config'); + const result = await configAiProvider(process.cwd(), provider); + + if (!provider) { + console.log( + chalk.blue( + `AI provider is '${result.provider}' for this repository.`, + ), + ); + return; + } + + if (result.changed) { + console.log(chalk.green(`✔ AI provider set to '${result.provider}'`)); + } else { + console.log( + chalk.yellow(`⚠ AI provider is already '${result.provider}'`), + ); + } + }), + ) + .addCommand( + new Command('ai-model') + .argument('[model]', 'Set repo-local model override (omit to inspect)') + .requiredOption( + '--provider ', + 'Provider name: gemini, gateway, or bedrock', + ) + .option('--clear', 'Clear the repo-local model override') + .description('Manage repo-local AI model overrides by provider') + .action( + async ( + model: string | undefined, + options: { + provider: string; + clear?: boolean; + }, + ) => { + const { configAiModel } = await import('./commands/config'); + const result = await configAiModel( + process.cwd(), + options.provider, + model, + { + clear: options.clear, + }, + ); + + if (!options.clear && model == null) { + console.log( + chalk.blue( + result.model + ? `AI model override for '${options.provider}' is '${result.model}' for this repository.` + : `AI model override for '${options.provider}' is not set for this repository.`, + ), + ); + return; + } + + if (result.changed) { + console.log( + chalk.green( + options.clear + ? `✔ Cleared AI model override for '${options.provider}'` + : `✔ AI model override for '${options.provider}' set to '${result.model}'`, + ), + ); + } else { + console.log( + chalk.yellow( + options.clear + ? `⚠ AI model override for '${options.provider}' is already clear` + : `⚠ AI model override for '${options.provider}' is already '${result.model}'`, + ), + ); + } + }, + ), ); program @@ -1005,6 +1120,34 @@ program .description( 'Use DubStack AI assistant utilities (or shortcut with: dub PROMPT)', ) + .addCommand( + new Command('setup') + .description('Guided setup for DubStack AI providers and model defaults') + .action(async () => { + const { aiSetup } = await import('./commands/ai-setup'); + const result = await aiSetup(process.cwd()); + + console.log(chalk.green(`✔ AI setup updated for '${result.provider}'`)); + console.log( + chalk.dim(` ↳ model: ${result.model} (${result.modelScope})`), + ); + if (result.updatedEnv.length > 0) { + console.log( + chalk.dim(` ↳ updated env: ${result.updatedEnv.join(', ')}`), + ); + if (result.profilePath) { + console.log(chalk.dim(` ↳ wrote profile: ${result.profilePath}`)); + } + if (result.activationCommand) { + console.log( + chalk.dim( + ` ↳ run in your shell to activate now: ${result.activationCommand}`, + ), + ); + } + } + }), + ) .addCommand( new Command('ask') .argument('', 'Prompt text to send to the AI assistant') @@ -1022,12 +1165,15 @@ program .addCommand( new Command('env') .description( - 'Write DubStack AI API keys/models to your shell profile (macOS/Linux)', + 'Write DubStack AI provider settings to your shell profile (macOS/Linux)', ) .option('--gemini-key ', 'Set DUBSTACK_GEMINI_API_KEY') .option('--gateway-key ', 'Set DUBSTACK_AI_GATEWAY_API_KEY') .option('--gemini-model ', 'Set DUBSTACK_GEMINI_MODEL') .option('--gateway-model ', 'Set DUBSTACK_AI_GATEWAY_MODEL') + .option('--bedrock-profile ', 'Set DUBSTACK_BEDROCK_AWS_PROFILE') + .option('--bedrock-region ', 'Set DUBSTACK_BEDROCK_AWS_REGION') + .option('--bedrock-model ', 'Set DUBSTACK_BEDROCK_MODEL') .option( '--profile ', 'Override target profile path (recommended for custom shells)', @@ -1042,6 +1188,9 @@ program gatewayKey?: string; geminiModel?: string; gatewayModel?: string; + bedrockProfile?: string; + bedrockRegion?: string; + bedrockModel?: string; profile?: string; shell?: string; }) => { @@ -1051,6 +1200,9 @@ program gatewayKey: options.gatewayKey, geminiModel: options.geminiModel, gatewayModel: options.gatewayModel, + bedrockProfile: options.bedrockProfile, + bedrockRegion: options.bedrockRegion, + bedrockModel: options.bedrockModel, profile: options.profile, shell: options.shell, }); @@ -1061,7 +1213,7 @@ program } console.log( chalk.dim( - `Run: source ${result.profilePath} (or open a new shell)`, + `Run in your shell to activate now: ${result.activationCommand}`, ), ); }, diff --git a/packages/cli/src/lib/ai-metadata.test.ts b/packages/cli/src/lib/ai-metadata.test.ts index 46ab15b..578ab28 100644 --- a/packages/cli/src/lib/ai-metadata.test.ts +++ b/packages/cli/src/lib/ai-metadata.test.ts @@ -5,6 +5,7 @@ import { generateFlowMetadata, generatePrDescriptionSummary, } from './ai-metadata'; +import type { DubConfig } from './config'; import { DubError } from './errors'; let envSnapshot: NodeJS.ProcessEnv; @@ -17,6 +18,17 @@ afterEach(() => { process.env = envSnapshot; }); +function createProviderConfig(): DubConfig['ai']['provider'] { + return { + selected: 'auto', + models: { + gemini: null, + gateway: null, + bedrock: null, + }, + }; +} + describe('generateCreateMetadata', () => { it('uses the Gemini provider when DUBSTACK_GEMINI_API_KEY is set', async () => { process.env.DUBSTACK_GEMINI_API_KEY = 'gem-key'; @@ -28,11 +40,16 @@ describe('generateCreateMetadata', () => { const createGoogleGenerativeAI = vi.fn().mockReturnValue(googleModel); const createGateway = vi.fn(); - const result = await generateCreateMetadata('diff --git a/file b/file', { - generateText, - createGoogleGenerativeAI, - createGateway, - }); + const result = await generateCreateMetadata( + 'diff --git a/file b/file', + { + generateText, + createGoogleGenerativeAI, + createGateway, + }, + {}, + createProviderConfig(), + ); expect(result).toEqual({ branch: 'feat/example', @@ -60,11 +77,16 @@ describe('generateCreateMetadata', () => { const gatewayModel = vi.fn().mockReturnValue('gateway-model'); const createGateway = vi.fn().mockReturnValue(gatewayModel); - await generateCreateMetadata('diff --git a/file b/file', { - generateText, - createGoogleGenerativeAI, - createGateway, - }); + await generateCreateMetadata( + 'diff --git a/file b/file', + { + generateText, + createGoogleGenerativeAI, + createGateway, + }, + {}, + createProviderConfig(), + ); expect(createGoogleGenerativeAI).not.toHaveBeenCalled(); expect(createGateway).toHaveBeenCalledWith({ apiKey: 'gateway-key' }); @@ -80,19 +102,29 @@ describe('generateCreateMetadata', () => { delete process.env.DUBSTACK_AI_GATEWAY_API_KEY; await expect( - generateCreateMetadata('diff --git a/file b/file', { - generateText: vi.fn(), - createGoogleGenerativeAI: vi.fn(), - createGateway: vi.fn(), - }), + generateCreateMetadata( + 'diff --git a/file b/file', + { + generateText: vi.fn(), + createGoogleGenerativeAI: vi.fn(), + createGateway: vi.fn(), + }, + {}, + createProviderConfig(), + ), ).rejects.toThrow(DubError); await expect( - generateCreateMetadata('diff --git a/file b/file', { - generateText: vi.fn(), - createGoogleGenerativeAI: vi.fn(), - createGateway: vi.fn(), - }), + generateCreateMetadata( + 'diff --git a/file b/file', + { + generateText: vi.fn(), + createGoogleGenerativeAI: vi.fn(), + createGateway: vi.fn(), + }, + {}, + createProviderConfig(), + ), ).rejects.toThrow('DUBSTACK_GEMINI_API_KEY'); }); @@ -100,13 +132,18 @@ describe('generateCreateMetadata', () => { process.env.DUBSTACK_GEMINI_API_KEY = 'gem-key'; await expect( - generateCreateMetadata('diff --git a/file b/file', { - generateText: vi.fn().mockResolvedValue({ - text: '{"branch":"feat/example"}', - }), - createGoogleGenerativeAI: vi.fn().mockReturnValue(vi.fn()), - createGateway: vi.fn(), - }), + generateCreateMetadata( + 'diff --git a/file b/file', + { + generateText: vi.fn().mockResolvedValue({ + text: '{"branch":"feat/example"}', + }), + createGoogleGenerativeAI: vi.fn().mockReturnValue(vi.fn()), + createGateway: vi.fn(), + }, + {}, + createProviderConfig(), + ), ).rejects.toThrow("AI assistant metadata is missing 'message'."); }); @@ -126,6 +163,7 @@ describe('generateCreateMetadata', () => { { commitTemplate: 'feat(scope): summary\n\n## Testing\n- [ ] added', }, + createProviderConfig(), ); const call = vi.mocked(generateText).mock.calls[0]?.[0]; @@ -162,6 +200,8 @@ index 1111111..2222222 100644 createGoogleGenerativeAI: vi.fn().mockReturnValue(vi.fn()), createGateway: vi.fn(), }, + {}, + createProviderConfig(), ); const call = vi.mocked(generateText).mock.calls[0]?.[0]; @@ -174,13 +214,18 @@ index 1111111..2222222 100644 it('normalizes extra whitespace in the commit subject while preserving the body', async () => { process.env.DUBSTACK_GEMINI_API_KEY = 'gem-key'; - const result = await generateCreateMetadata('diff --git a/file b/file', { - generateText: vi.fn().mockResolvedValue({ - text: '{"branch":"feat/example","message":"feat: example subject\\n\\n## Testing\\n- [x] added"}', - }), - createGoogleGenerativeAI: vi.fn().mockReturnValue(vi.fn()), - createGateway: vi.fn(), - }); + const result = await generateCreateMetadata( + 'diff --git a/file b/file', + { + generateText: vi.fn().mockResolvedValue({ + text: '{"branch":"feat/example","message":"feat: example subject\\n\\n## Testing\\n- [x] added"}', + }), + createGoogleGenerativeAI: vi.fn().mockReturnValue(vi.fn()), + createGateway: vi.fn(), + }, + {}, + createProviderConfig(), + ); expect(result.message).toBe( 'feat: example subject\n\n## Testing\n- [x] added', @@ -211,6 +256,8 @@ describe('generatePrDescriptionSummary', () => { createGoogleGenerativeAI, createGateway, }, + {}, + createProviderConfig(), ); expect(result).toBe('## Summary\n\nAdds the new submit AI flow.'); @@ -237,6 +284,8 @@ describe('generatePrDescriptionSummary', () => { createGoogleGenerativeAI: vi.fn().mockReturnValue(vi.fn()), createGateway: vi.fn(), }, + {}, + createProviderConfig(), ), ).rejects.toThrow('AI assistant generated an empty PR description.'); }); @@ -262,6 +311,7 @@ describe('generatePrDescriptionSummary', () => { { prTemplate: '## Summary\n\n## Testing', }, + createProviderConfig(), ); const call = vi.mocked(generateText).mock.calls[0]?.[0]; @@ -298,6 +348,8 @@ describe('generateFlowMetadata', () => { createGoogleGenerativeAI, createGateway, }, + {}, + createProviderConfig(), ); expect(result).toEqual({ @@ -334,6 +386,7 @@ describe('generateFlowMetadata', () => { commitTemplate: 'feat(scope): summary\n\n## Testing\n- [ ] added', prTemplate: '## Summary\n\n## Testing', }, + createProviderConfig(), ); const createCall = vi.mocked(generateText).mock.calls[0]?.[0]; diff --git a/packages/cli/src/lib/ai-metadata.ts b/packages/cli/src/lib/ai-metadata.ts index 9ec2fe3..e991636 100644 --- a/packages/cli/src/lib/ai-metadata.ts +++ b/packages/cli/src/lib/ai-metadata.ts @@ -1,16 +1,26 @@ +import type { createAmazonBedrock } from '@ai-sdk/amazon-bedrock'; import type { createGoogleGenerativeAI } from '@ai-sdk/google'; -import type { createGateway, generateText, LanguageModel } from 'ai'; +import type { + fromIni, + fromNodeProviderChain, +} from '@aws-sdk/credential-providers'; +import type { createGateway, generateText } from 'ai'; import { type AiDiffContext, type AiDiffContextInput, buildAiDiffContext, } from './ai-diff-context'; +import { resolveAiProvider } from './ai-provider'; +import type { DubConfig } from './config'; import { DubError } from './errors'; export interface AiMetadataDependencies { generateText: typeof generateText; createGoogleGenerativeAI: typeof createGoogleGenerativeAI; createGateway: typeof createGateway; + createAmazonBedrock?: typeof createAmazonBedrock; + fromIni?: typeof fromIni; + fromNodeProviderChain?: typeof fromNodeProviderChain; } export interface PrDescriptionContext { @@ -37,8 +47,9 @@ export async function generateCreateMetadata( stagedDiff: AiDiffContext | string | AiDiffContextInput, deps: AiMetadataDependencies, templates: AiMetadataTemplates = {}, + providerConfig: DubConfig['ai']['provider'], ): Promise<{ branch: string; message: string }> { - const resolved = resolveModel(deps); + const resolved = resolveAiProvider({ deps, providerConfig }); const diffContext = resolveAiDiffContext(stagedDiff); const prompt = [ 'Generate a git branch name and conventional commit message for the entire staged change set.', @@ -79,8 +90,9 @@ export async function generatePrDescriptionSummary( context: PrDescriptionContext, deps: AiMetadataDependencies, templates: AiMetadataTemplates = {}, + providerConfig: DubConfig['ai']['provider'], ): Promise { - const resolved = resolveModel(deps); + const resolved = resolveAiProvider({ deps, providerConfig }); const diffContext = resolveAiDiffContext(context.diff); const prompt = [ 'Write a concise pull request description in markdown.', @@ -126,12 +138,18 @@ export async function generateFlowMetadata( input: FlowMetadataInput, deps: AiMetadataDependencies, templates: AiMetadataTemplates = {}, + providerConfig: DubConfig['ai']['provider'], ): Promise<{ branch: string; commitMessage: string; prDescription: string; }> { - const generated = await generateCreateMetadata(input.staged, deps, templates); + const generated = await generateCreateMetadata( + input.staged, + deps, + templates, + providerConfig, + ); const prDescription = await generatePrDescriptionSummary( { branch: generated.branch, @@ -141,6 +159,7 @@ export async function generateFlowMetadata( }, deps, templates, + providerConfig, ); return { @@ -150,40 +169,6 @@ export async function generateFlowMetadata( }; } -function resolveModel(deps: AiMetadataDependencies): { - provider: 'google' | 'gateway'; - model: LanguageModel; - modelId: string; -} { - const geminiApiKey = process.env.DUBSTACK_GEMINI_API_KEY?.trim(); - if (geminiApiKey) { - const geminiModel = - process.env.DUBSTACK_GEMINI_MODEL?.trim() || 'gemini-3-flash-preview'; - const google = deps.createGoogleGenerativeAI({ apiKey: geminiApiKey }); - return { - provider: 'google', - model: google(geminiModel), - modelId: geminiModel, - }; - } - - const gatewayApiKey = process.env.DUBSTACK_AI_GATEWAY_API_KEY?.trim(); - if (gatewayApiKey) { - const gatewayModel = - process.env.DUBSTACK_AI_GATEWAY_MODEL?.trim() || 'google/gemini-3-flash'; - const gateway = deps.createGateway({ apiKey: gatewayApiKey }); - return { - provider: 'gateway', - model: gateway(gatewayModel), - modelId: gatewayModel, - }; - } - - throw new DubError( - "AI assistant requires DUBSTACK_GEMINI_API_KEY or DUBSTACK_AI_GATEWAY_API_KEY. Run 'dub ai env --gemini-key ' or 'dub ai env --gateway-key '.", - ); -} - function parseAiCreateResponse(text: string): { branch: string; message: string; diff --git a/packages/cli/src/lib/ai-provider.test.ts b/packages/cli/src/lib/ai-provider.test.ts new file mode 100644 index 0000000..963c855 --- /dev/null +++ b/packages/cli/src/lib/ai-provider.test.ts @@ -0,0 +1,223 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + buildAiProviderOptions, + type ResolveAiProviderDeps, + resolveAiProvider, +} from './ai-provider'; +import type { DubConfig } from './config'; + +let envSnapshot: NodeJS.ProcessEnv; + +beforeEach(() => { + envSnapshot = { ...process.env }; +}); + +afterEach(() => { + process.env = envSnapshot; +}); + +function createDeps(): ResolveAiProviderDeps { + return { + createGoogleGenerativeAI: vi.fn().mockReturnValue(vi.fn()), + createGateway: vi.fn().mockReturnValue(vi.fn()), + createAmazonBedrock: vi.fn().mockReturnValue(vi.fn()), + fromIni: vi.fn().mockReturnValue('ini-credentials'), + fromNodeProviderChain: vi.fn().mockReturnValue('default-chain'), + }; +} + +function createConfig( + overrides: { + selected?: DubConfig['ai']['provider']['selected']; + models?: Partial; + } = {}, +): DubConfig['ai']['provider'] { + const models = { + gemini: null, + gateway: null, + bedrock: null, + ...overrides.models, + }; + + return { + selected: 'auto', + ...overrides, + models, + }; +} + +describe('resolveAiProvider', () => { + it('uses the explicitly selected Bedrock provider and repo model override', () => { + process.env.DUBSTACK_GEMINI_API_KEY = 'gem-key'; + process.env.DUBSTACK_BEDROCK_AWS_PROFILE = 'bw-sso'; + process.env.DUBSTACK_BEDROCK_AWS_REGION = 'us-west-2'; + process.env.DUBSTACK_BEDROCK_MODEL = 'env-bedrock-model'; + + const bedrockModel = vi.fn().mockReturnValue('bedrock-model'); + const deps = createDeps(); + deps.createAmazonBedrock = vi.fn().mockReturnValue(bedrockModel); + + const resolved = resolveAiProvider({ + deps, + providerConfig: createConfig({ + selected: 'bedrock', + models: { + bedrock: 'repo-bedrock-model', + }, + }), + }); + + expect(deps.fromIni).toHaveBeenCalledWith({ profile: 'bw-sso' }); + expect(deps.fromNodeProviderChain).not.toHaveBeenCalled(); + expect(deps.createAmazonBedrock).toHaveBeenCalledWith({ + region: 'us-west-2', + credentialProvider: 'ini-credentials', + }); + expect(bedrockModel).toHaveBeenCalledWith('repo-bedrock-model'); + expect(resolved.provider).toBe('bedrock'); + expect(resolved.modelId).toBe('repo-bedrock-model'); + }); + + it('uses the default AWS credential chain when no Bedrock profile is set', () => { + process.env.DUBSTACK_BEDROCK_AWS_REGION = 'us-east-1'; + process.env.DUBSTACK_BEDROCK_MODEL = 'us.anthropic.claude-sonnet-4-6'; + + const deps = createDeps(); + const bedrockModel = vi.fn().mockReturnValue('bedrock-model'); + deps.createAmazonBedrock = vi.fn().mockReturnValue(bedrockModel); + + resolveAiProvider({ + deps, + providerConfig: createConfig({ selected: 'bedrock' }), + }); + + expect(deps.fromIni).not.toHaveBeenCalled(); + expect(deps.fromNodeProviderChain).toHaveBeenCalledTimes(1); + expect(deps.createAmazonBedrock).toHaveBeenCalledWith({ + region: 'us-east-1', + credentialProvider: 'default-chain', + }); + }); + + it('uses gateway when selected explicitly even if gemini is configured', () => { + process.env.DUBSTACK_GEMINI_API_KEY = 'gem-key'; + process.env.DUBSTACK_AI_GATEWAY_API_KEY = 'gateway-key'; + process.env.DUBSTACK_AI_GATEWAY_MODEL = 'google/gemini-2.5-pro'; + + const deps = createDeps(); + const gatewayModel = vi.fn().mockReturnValue('gateway-model'); + deps.createGateway = vi.fn().mockReturnValue(gatewayModel); + + const resolved = resolveAiProvider({ + deps, + providerConfig: createConfig({ selected: 'gateway' }), + }); + + expect(deps.createGoogleGenerativeAI).not.toHaveBeenCalled(); + expect(deps.createGateway).toHaveBeenCalledWith({ apiKey: 'gateway-key' }); + expect(gatewayModel).toHaveBeenCalledWith('google/gemini-2.5-pro'); + expect(resolved.provider).toBe('gateway'); + }); + + it('preserves the existing auto fallback order of gemini, gateway, then bedrock', () => { + process.env.DUBSTACK_GEMINI_API_KEY = 'gem-key'; + process.env.DUBSTACK_AI_GATEWAY_API_KEY = 'gateway-key'; + process.env.DUBSTACK_BEDROCK_AWS_REGION = 'us-east-1'; + process.env.DUBSTACK_BEDROCK_MODEL = 'bedrock-model'; + + const deps = createDeps(); + const googleModel = vi.fn().mockReturnValue('google-model'); + deps.createGoogleGenerativeAI = vi.fn().mockReturnValue(googleModel); + + const resolved = resolveAiProvider({ + deps, + providerConfig: createConfig(), + }); + + expect(deps.createGoogleGenerativeAI).toHaveBeenCalledWith({ + apiKey: 'gem-key', + }); + expect(resolved.provider).toBe('google'); + }); + + it('throws when Bedrock is selected without a configured region', () => { + process.env.DUBSTACK_BEDROCK_MODEL = 'bedrock-model'; + + expect(() => + resolveAiProvider({ + deps: createDeps(), + providerConfig: createConfig({ selected: 'bedrock' }), + }), + ).toThrow('DUBSTACK_BEDROCK_AWS_REGION'); + }); +}); + +describe('buildAiProviderOptions', () => { + it('returns google thinking and search grounding only for google models', () => { + expect( + buildAiProviderOptions( + { + provider: 'google', + modelId: 'gemini-3-flash-preview', + }, + { + withWebBrowsing: true, + }, + ), + ).toEqual({ + google: { + thinkingConfig: { + thinkingLevel: 'high', + includeThoughts: true, + }, + useSearchGrounding: true, + }, + }); + }); + + it('returns Bedrock reasoning options for reasoning-capable Anthropic models', () => { + expect( + buildAiProviderOptions( + { + provider: 'bedrock', + modelId: 'us.anthropic.claude-sonnet-4-6', + }, + { + withWebBrowsing: true, + }, + ), + ).toEqual({ + bedrock: { + reasoningConfig: { + type: 'enabled', + budgetTokens: 4096, + }, + }, + }); + }); + + it('returns no provider options for gateway or non-reasoning Bedrock models', () => { + expect( + buildAiProviderOptions( + { + provider: 'gateway', + modelId: 'google/gemini-3-flash', + }, + { + withWebBrowsing: true, + }, + ), + ).toEqual({}); + expect( + buildAiProviderOptions( + { + provider: 'bedrock', + modelId: 'amazon.nova-lite-v1:0', + }, + { + withWebBrowsing: true, + }, + ), + ).toEqual({}); + }); +}); diff --git a/packages/cli/src/lib/ai-provider.ts b/packages/cli/src/lib/ai-provider.ts new file mode 100644 index 0000000..3e142a3 --- /dev/null +++ b/packages/cli/src/lib/ai-provider.ts @@ -0,0 +1,214 @@ +import type { createAmazonBedrock } from '@ai-sdk/amazon-bedrock'; +import type { createGoogleGenerativeAI } from '@ai-sdk/google'; +import type { + fromIni, + fromNodeProviderChain, +} from '@aws-sdk/credential-providers'; +import type { createGateway, LanguageModel } from 'ai'; +import type { DubConfig } from './config'; +import { DubError } from './errors'; + +export type ResolvedAiProviderName = 'google' | 'gateway' | 'bedrock'; + +export interface ResolveAiProviderDeps { + createGoogleGenerativeAI: typeof createGoogleGenerativeAI; + createGateway: typeof createGateway; + createAmazonBedrock?: typeof createAmazonBedrock; + fromIni?: typeof fromIni; + fromNodeProviderChain?: typeof fromNodeProviderChain; +} + +export interface ResolvedAiProvider { + provider: ResolvedAiProviderName; + model: LanguageModel; + modelId: string; +} + +export function resolveAiProvider(input: { + deps: ResolveAiProviderDeps; + providerConfig: DubConfig['ai']['provider']; +}): ResolvedAiProvider { + const providerConfig = input.providerConfig; + const selected = providerConfig.selected; + + if (selected === 'gemini') { + return resolveGoogleProvider(input.deps, providerConfig); + } + + if (selected === 'gateway') { + return resolveGatewayProvider(input.deps, providerConfig); + } + + if (selected === 'bedrock') { + return resolveBedrockProvider(input.deps, providerConfig); + } + + const geminiApiKey = process.env.DUBSTACK_GEMINI_API_KEY?.trim(); + if (geminiApiKey) { + return resolveGoogleProvider(input.deps, providerConfig); + } + + const gatewayApiKey = process.env.DUBSTACK_AI_GATEWAY_API_KEY?.trim(); + if (gatewayApiKey) { + return resolveGatewayProvider(input.deps, providerConfig); + } + + const bedrockRegion = process.env.DUBSTACK_BEDROCK_AWS_REGION?.trim(); + const bedrockModel = getConfiguredModel('bedrock', providerConfig); + if (bedrockRegion && bedrockModel) { + return resolveBedrockProvider(input.deps, providerConfig); + } + + throw new DubError( + "AI assistant requires one of: DUBSTACK_GEMINI_API_KEY, DUBSTACK_AI_GATEWAY_API_KEY, or the Bedrock pair DUBSTACK_BEDROCK_AWS_REGION + DUBSTACK_BEDROCK_MODEL. Run 'dub ai setup' or 'dub ai env' to configure a provider.", + ); +} + +export function buildAiProviderOptions( + provider: Pick, + options: { withWebBrowsing: boolean }, +): Record { + if (provider.provider === 'google') { + const googleOptions: Record = { + thinkingConfig: { + thinkingLevel: 'high', + includeThoughts: true, + }, + }; + if (options.withWebBrowsing) { + googleOptions.useSearchGrounding = true; + } + return { google: googleOptions }; + } + + if ( + provider.provider === 'bedrock' && + supportsBedrockReasoning(provider.modelId) + ) { + return { + bedrock: { + reasoningConfig: { + type: 'enabled', + budgetTokens: 4096, + }, + }, + }; + } + + return {}; +} + +function resolveGoogleProvider( + deps: ResolveAiProviderDeps, + providerConfig: DubConfig['ai']['provider'], +): ResolvedAiProvider { + const geminiApiKey = process.env.DUBSTACK_GEMINI_API_KEY?.trim(); + if (!geminiApiKey) { + throw new DubError( + "Gemini is selected but DUBSTACK_GEMINI_API_KEY is not set. Run 'dub ai setup' or 'dub ai env --gemini-key '.", + ); + } + + const geminiModel = + getConfiguredModel('gemini', providerConfig) || 'gemini-3-flash-preview'; + const google = deps.createGoogleGenerativeAI({ apiKey: geminiApiKey }); + + return { + provider: 'google', + model: google(geminiModel), + modelId: geminiModel, + }; +} + +function resolveGatewayProvider( + deps: ResolveAiProviderDeps, + providerConfig: DubConfig['ai']['provider'], +): ResolvedAiProvider { + const gatewayApiKey = process.env.DUBSTACK_AI_GATEWAY_API_KEY?.trim(); + if (!gatewayApiKey) { + throw new DubError( + "AI Gateway is selected but DUBSTACK_AI_GATEWAY_API_KEY is not set. Run 'dub ai setup' or 'dub ai env --gateway-key '.", + ); + } + + const gatewayModel = + getConfiguredModel('gateway', providerConfig) || 'google/gemini-3-flash'; + const gateway = deps.createGateway({ apiKey: gatewayApiKey }); + + return { + provider: 'gateway', + model: gateway(gatewayModel), + modelId: gatewayModel, + }; +} + +function resolveBedrockProvider( + deps: ResolveAiProviderDeps, + providerConfig: DubConfig['ai']['provider'], +): ResolvedAiProvider { + if ( + !deps.createAmazonBedrock || + !deps.fromIni || + !deps.fromNodeProviderChain + ) { + throw new DubError('Bedrock support is unavailable in this build.'); + } + + const region = process.env.DUBSTACK_BEDROCK_AWS_REGION?.trim(); + if (!region) { + throw new DubError( + "Bedrock is selected but DUBSTACK_BEDROCK_AWS_REGION is not set. Run 'dub ai setup' or 'dub ai env --bedrock-region '.", + ); + } + + const modelId = getConfiguredModel('bedrock', providerConfig); + if (!modelId) { + throw new DubError( + "Bedrock is selected but DUBSTACK_BEDROCK_MODEL is not set and no repo override exists. Run 'dub ai setup' or 'dub ai env --bedrock-model '.", + ); + } + + const profile = process.env.DUBSTACK_BEDROCK_AWS_PROFILE?.trim(); + const credentialProvider = profile + ? deps.fromIni({ profile }) + : deps.fromNodeProviderChain(); + const bedrock = deps.createAmazonBedrock({ + region, + credentialProvider, + }); + + return { + provider: 'bedrock', + model: bedrock(modelId), + modelId, + }; +} + +function getConfiguredModel( + provider: keyof DubConfig['ai']['provider']['models'], + providerConfig: DubConfig['ai']['provider'], +): string | null { + const repoModel = providerConfig.models[provider]; + if (repoModel?.trim()) { + return repoModel.trim(); + } + + if (provider === 'gemini') { + return normalizeEnvModel(process.env.DUBSTACK_GEMINI_MODEL); + } + + if (provider === 'gateway') { + return normalizeEnvModel(process.env.DUBSTACK_AI_GATEWAY_MODEL); + } + + return normalizeEnvModel(process.env.DUBSTACK_BEDROCK_MODEL); +} + +function normalizeEnvModel(value: string | undefined): string | null { + const model = value?.trim(); + return model ? model : null; +} + +function supportsBedrockReasoning(modelId: string): boolean { + return /claude-3-7|claude-(sonnet|opus|haiku)-4/.test(modelId); +} diff --git a/packages/cli/src/lib/browser.test.ts b/packages/cli/src/lib/browser.test.ts new file mode 100644 index 0000000..886dc6f --- /dev/null +++ b/packages/cli/src/lib/browser.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('execa', () => ({ + execa: vi.fn(), +})); + +import { execa } from 'execa'; +import { openUrl } from './browser'; + +const mockExeca = execa as unknown as ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + mockExeca.mockResolvedValue({ stdout: '' }); +}); + +describe('openUrl', () => { + it('uses open on macOS', async () => { + await openUrl('https://dubstack.dev/docs', { + platform: 'darwin', + }); + + expect(mockExeca).toHaveBeenCalledWith( + 'open', + ['https://dubstack.dev/docs'], + { stdio: 'ignore' }, + ); + }); + + it('uses xdg-open on Linux', async () => { + await openUrl('https://dubstack.dev/docs', { + platform: 'linux', + }); + + expect(mockExeca).toHaveBeenCalledWith( + 'xdg-open', + ['https://dubstack.dev/docs'], + { stdio: 'ignore' }, + ); + }); + + it('throws on unsupported platforms', async () => { + await expect( + openUrl('https://dubstack.dev/docs', { + platform: 'freebsd', + }), + ).rejects.toThrow('Unsupported platform'); + }); +}); diff --git a/packages/cli/src/lib/browser.ts b/packages/cli/src/lib/browser.ts new file mode 100644 index 0000000..789083c --- /dev/null +++ b/packages/cli/src/lib/browser.ts @@ -0,0 +1,23 @@ +import { execa } from 'execa'; +import { DubError } from './errors'; + +export async function openUrl( + url: string, + options: { platform?: NodeJS.Platform } = {}, +): Promise { + const platform = options.platform ?? process.platform; + const opener = resolveOpener(platform); + + try { + await execa(opener, [url], { stdio: 'ignore' }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new DubError(`Failed to open '${url}' in your browser: ${message}`); + } +} + +function resolveOpener(platform: NodeJS.Platform): string { + if (platform === 'darwin') return 'open'; + if (platform === 'linux') return 'xdg-open'; + throw new DubError(`Unsupported platform '${platform}' for browser opening.`); +} diff --git a/packages/cli/src/lib/config.test.ts b/packages/cli/src/lib/config.test.ts index 3182c57..2ebd154 100644 --- a/packages/cli/src/lib/config.test.ts +++ b/packages/cli/src/lib/config.test.ts @@ -29,6 +29,14 @@ describe('readConfig', () => { submitDescription: false, flow: false, }, + provider: { + selected: 'auto', + models: { + gemini: null, + gateway: null, + bedrock: null, + }, + }, shortcutFallback: { enabled: true, typoGuard: 'interactive', @@ -68,6 +76,14 @@ describe('writeConfig', () => { submitDescription: false, flow: false, }); + expect(config.ai.provider).toEqual({ + selected: 'auto', + models: { + gemini: null, + gateway: null, + bedrock: null, + }, + }); }); it('fills in missing ai defaults when persisting partial config', async () => { @@ -91,5 +107,71 @@ describe('writeConfig', () => { submitDescription: false, flow: false, }); + expect(config.ai.provider).toEqual({ + selected: 'auto', + models: { + gemini: null, + gateway: null, + bedrock: null, + }, + }); + }); + + it('persists ai provider selection and per-provider model overrides', async () => { + await writeConfig( + { + ai: { + provider: { + selected: 'bedrock', + models: { + bedrock: 'us.anthropic.claude-sonnet-4-6', + }, + }, + }, + }, + dir, + ); + + const config = await readConfig(dir); + + expect(config.ai.provider).toEqual({ + selected: 'bedrock', + models: { + gemini: null, + gateway: null, + bedrock: 'us.anthropic.claude-sonnet-4-6', + }, + }); + }); + + it('normalizes invalid provider settings back to defaults', async () => { + const dubDir = path.join(dir, '.git', 'dubstack'); + fs.mkdirSync(dubDir, { recursive: true }); + fs.writeFileSync( + path.join(dubDir, 'config.json'), + JSON.stringify({ + ai: { + provider: { + selected: 'unknown', + models: { + gemini: 123, + gateway: '', + bedrock: ' ', + }, + }, + }, + }), + ); + + const config = await readConfig(dir); + + expect(config.ai.provider).toEqual({ + selected: 'auto', + models: { + gemini: null, + gateway: null, + bedrock: null, + }, + }); }); }); diff --git a/packages/cli/src/lib/config.ts b/packages/cli/src/lib/config.ts index 299e1d2..bf5dba0 100644 --- a/packages/cli/src/lib/config.ts +++ b/packages/cli/src/lib/config.ts @@ -11,6 +11,14 @@ export interface DubConfig { submitDescription: boolean; flow: boolean; }; + provider: { + selected: 'auto' | 'gemini' | 'gateway' | 'bedrock'; + models: { + gemini: string | null; + gateway: string | null; + bedrock: string | null; + }; + }; shortcutFallback: { enabled: boolean; typoGuard: 'interactive'; @@ -44,6 +52,14 @@ const DEFAULT_CONFIG: DubConfig = { submitDescription: false, flow: false, }, + provider: { + selected: 'auto', + models: { + gemini: null, + gateway: null, + bedrock: null, + }, + }, shortcutFallback: { enabled: true, typoGuard: 'interactive', @@ -99,6 +115,7 @@ export async function writeConfig( function normalizeConfig(config: DeepPartial): DubConfig { const defaults = config.ai?.defaults; + const provider = config.ai?.provider; const fallback = config.ai?.shortcutFallback; const shellHistory = config.ai?.context?.shellHistory; const webBrowsing = config.ai?.webBrowsing; @@ -123,6 +140,14 @@ function normalizeConfig(config: DeepPartial): DubConfig { ? defaults.flow : DEFAULT_CONFIG.ai.defaults.flow, }, + provider: { + selected: normalizeAiProviderSelection(provider?.selected), + models: { + gemini: normalizeAiProviderModel(provider?.models?.gemini), + gateway: normalizeAiProviderModel(provider?.models?.gateway), + bedrock: normalizeAiProviderModel(provider?.models?.bedrock), + }, + }, shortcutFallback: { enabled: typeof fallback?.enabled === 'boolean' @@ -163,3 +188,23 @@ function normalizeConfig(config: DeepPartial): DubConfig { }, }; } + +function normalizeAiProviderSelection( + value: unknown, +): DubConfig['ai']['provider']['selected'] { + if ( + value === 'auto' || + value === 'gemini' || + value === 'gateway' || + value === 'bedrock' + ) { + return value; + } + return DEFAULT_CONFIG.ai.provider.selected; +} + +function normalizeAiProviderModel(value: unknown): string | null { + if (typeof value !== 'string') return null; + const model = value.trim(); + return model.length > 0 ? model : null; +} diff --git a/packages/cli/src/lib/external-links.ts b/packages/cli/src/lib/external-links.ts new file mode 100644 index 0000000..f13bf23 --- /dev/null +++ b/packages/cli/src/lib/external-links.ts @@ -0,0 +1 @@ +export const DUBSTACK_DOCS_URL = 'https://www.dubstack.dev/docs'; diff --git a/packages/cli/src/lib/github.test.ts b/packages/cli/src/lib/github.test.ts index 69b809a..fa99688 100644 --- a/packages/cli/src/lib/github.test.ts +++ b/packages/cli/src/lib/github.test.ts @@ -21,6 +21,7 @@ import { getPr, getPrByNumber, getPrStateByNumber, + getRepositoryWebUrl, mergePr, openPrInBrowser, retargetPrBase, @@ -309,3 +310,71 @@ describe('openPrInBrowser', () => { ); }); }); + +describe('getRepositoryWebUrl', () => { + it('uses the upstream remote when available', async () => { + mockExeca + .mockResolvedValueOnce({ stdout: 'origin/feat/a' }) + .mockResolvedValueOnce({ + stdout: 'git@github.com:wiseiodev/dubstack.git', + }); + + await expect(getRepositoryWebUrl('/repo')).resolves.toBe( + 'https://github.com/wiseiodev/dubstack', + ); + expect(mockExeca).toHaveBeenNthCalledWith( + 1, + 'git', + ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], + { cwd: '/repo' }, + ); + expect(mockExeca).toHaveBeenNthCalledWith( + 2, + 'git', + ['remote', 'get-url', 'origin'], + { cwd: '/repo' }, + ); + }); + + it('falls back to origin when the current branch has no upstream', async () => { + mockExeca + .mockRejectedValueOnce(new Error('no upstream configured')) + .mockResolvedValueOnce({ + stdout: 'https://github.com/wiseiodev/dubstack.git', + }); + + await expect(getRepositoryWebUrl('/repo')).resolves.toBe( + 'https://github.com/wiseiodev/dubstack', + ); + expect(mockExeca).toHaveBeenNthCalledWith( + 2, + 'git', + ['remote', 'get-url', 'origin'], + { cwd: '/repo' }, + ); + }); + + it('accepts ssh:// GitHub remotes', async () => { + mockExeca + .mockRejectedValueOnce(new Error('no upstream configured')) + .mockResolvedValueOnce({ + stdout: 'ssh://git@github.com/wiseiodev/dubstack.git', + }); + + await expect(getRepositoryWebUrl('/repo')).resolves.toBe( + 'https://github.com/wiseiodev/dubstack', + ); + }); + + it('throws for non-GitHub remotes', async () => { + mockExeca + .mockRejectedValueOnce(new Error('no upstream configured')) + .mockResolvedValueOnce({ + stdout: 'git@gitlab.com:wiseiodev/dubstack.git', + }); + + await expect(getRepositoryWebUrl('/repo')).rejects.toThrow( + 'does not point to GitHub', + ); + }); +}); diff --git a/packages/cli/src/lib/github.ts b/packages/cli/src/lib/github.ts index ab8cd05..fbe5a55 100644 --- a/packages/cli/src/lib/github.ts +++ b/packages/cli/src/lib/github.ts @@ -1,4 +1,5 @@ import { execa } from 'execa'; +import { openUrl } from './browser'; import { DubError } from './errors'; /** Details of a GitHub Pull Request. */ @@ -370,6 +371,31 @@ export async function mergePr( } } +export async function getRepositoryWebUrl(cwd: string): Promise { + const remote = await getPreferredRemote(cwd); + let remoteUrl: string; + + try { + const result = await execa('git', ['remote', 'get-url', remote], { cwd }); + remoteUrl = result.stdout.trim(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.toLowerCase().includes('not a git repository')) { + throw new DubError( + 'Not a git repository. Run this command inside a git repo.', + ); + } + throw new DubError(`Failed to read git remote '${remote}': ${message}`); + } + + return normalizeGitHubRepositoryUrl(remoteUrl); +} + +export async function openRepositoryInBrowser(cwd: string): Promise { + const url = await getRepositoryWebUrl(cwd); + await openUrl(url); +} + /** * Opens a PR in the browser via GitHub CLI. * @@ -401,3 +427,44 @@ export async function openPrInBrowser( ); } } + +async function getPreferredRemote(cwd: string): Promise { + try { + const result = await execa( + 'git', + ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], + { cwd }, + ); + const upstream = result.stdout.trim(); + const slashIndex = upstream.indexOf('/'); + if (slashIndex > 0) { + return upstream.slice(0, slashIndex); + } + } catch {} + + return 'origin'; +} + +function normalizeGitHubRepositoryUrl(remoteUrl: string): string { + const trimmed = remoteUrl.trim(); + const sshMatch = trimmed.match(/^git@github\.com:(.+?)(?:\.git)?$/); + if (sshMatch) { + return `https://github.com/${sshMatch[1]}`; + } + + const sshProtocolMatch = trimmed.match( + /^ssh:\/\/git@github\.com\/(.+?)(?:\.git)?$/, + ); + if (sshProtocolMatch) { + return `https://github.com/${sshProtocolMatch[1]}`; + } + + const httpsMatch = trimmed.match(/^https:\/\/github\.com\/(.+?)(?:\.git)?$/); + if (httpsMatch) { + return `https://github.com/${httpsMatch[1]}`; + } + + throw new DubError( + `Remote URL '${trimmed}' does not point to GitHub. 'dub repo' currently supports GitHub remotes only.`, + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c2fd4bf..9fcbb2f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -78,12 +78,27 @@ importers: packages/cli: dependencies: + '@ai-sdk/amazon-bedrock': + specifier: ^4.0.77 + version: 4.0.77(zod@4.3.6) '@ai-sdk/google': specifier: ^3.0.30 version: 3.0.30(zod@4.3.6) + '@aws-sdk/credential-providers': + specifier: ^3.1004.0 + version: 3.1004.0 + '@inquirer/input': + specifier: ^5.0.8 + version: 5.0.8(@types/node@25.2.3) + '@inquirer/password': + specifier: ^5.0.8 + version: 5.0.8(@types/node@25.2.3) '@inquirer/search': specifier: ^4.1.3 version: 4.1.3(@types/node@25.2.3) + '@inquirer/select': + specifier: ^5.1.0 + version: 5.1.0(@types/node@25.2.3) ai: specifier: ^6.0.97 version: 6.0.97(zod@4.3.6) @@ -124,6 +139,18 @@ importers: packages: + '@ai-sdk/amazon-bedrock@4.0.77': + resolution: {integrity: sha512-PKLqycbq4NHCasZ0454HZh58thDGsqEKnpkXYr+Ym4Y4YFL3vhLUwhvvZeuOfawVdmMijQfRiKeAZhd2XtTkOg==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/anthropic@3.0.58': + resolution: {integrity: sha512-/53SACgmVukO4bkms4dpxpRlYhW8Ct6QZRe6sj1Pi5H00hYhxIrqfiLbZBGxkdRvjsBQeP/4TVGsXgH5rQeb8Q==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/gateway@3.0.53': resolution: {integrity: sha512-QT3FEoNARMRlk8JJVR7L98exiK9C8AGfrEJVbRxBT1yIXKs/N19o/+PsjTRVsARgDJNcy9JbJp1FspKucEat0Q==} engines: {node: '>=18'} @@ -142,6 +169,12 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider-utils@4.0.19': + resolution: {integrity: sha512-3eG55CrSWCu2SXlqq2QCsFjo3+E7+Gmg7i/oRVoSZzIodTuDSfLb3MRje67xE9RFea73Zao7Lm4mADIfUETKGg==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider@2.0.1': resolution: {integrity: sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng==} engines: {node: '>=18'} @@ -154,6 +187,131 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@aws-crypto/crc32@5.2.0': + resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/sha256-browser@5.2.0': + resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} + + '@aws-crypto/sha256-js@5.2.0': + resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/supports-web-crypto@5.2.0': + resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} + + '@aws-crypto/util@5.2.0': + resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + + '@aws-sdk/client-cognito-identity@3.1004.0': + resolution: {integrity: sha512-iRFVMN0Rlh9tjEuz1c6eQnv9EiYH0uxIvobsn5IvOjsM0PdfsKpGdRKiQIA/OgmpTPfuYyySwaRRtDFH9TMlQw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.973.18': + resolution: {integrity: sha512-GUIlegfcK2LO1J2Y98sCJy63rQSiLiDOgVw7HiHPRqfI2vb3XozTVqemwO0VSGXp54ngCnAQz0Lf0YPCBINNxA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-cognito-identity@3.972.10': + resolution: {integrity: sha512-R7saD8TvU6En8tFstAgbM9w6wlFxTwXrvMEpheVdGyDMKSxK412aRy87VNb2Mc2By0vL58OIE487afpxOc/rVQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.16': + resolution: {integrity: sha512-HrdtnadvTGAQUr18sPzGlE5El3ICphnH6SU7UQOMOWFgRKbTRNN8msTxM4emzguUso9CzaHU2xy5ctSrmK5YNA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.18': + resolution: {integrity: sha512-NyB6smuZAixND5jZumkpkunQ0voc4Mwgkd+SZ6cvAzIB7gK8HV8Zd4rS8Kn5MmoGgusyNfVGG+RLoYc4yFiw+A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.972.17': + resolution: {integrity: sha512-dFqh7nfX43B8dO1aPQHOcjC0SnCJ83H3F+1LoCh3X1P7E7N09I+0/taID0asU6GCddfDExqnEvQtDdkuMe5tKQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.17': + resolution: {integrity: sha512-gf2E5b7LpKb+JX2oQsRIDxdRZjBFZt2olCGlWCdb3vBERbXIPgm2t1R5mEnwd4j0UEO/Tbg5zN2KJbHXttJqwA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.18': + resolution: {integrity: sha512-ZDJa2gd1xiPg/nBDGhUlat02O8obaDEnICBAVS8qieZ0+nDfaB0Z3ec6gjZj27OqFTjnB/Q5a0GwQwb7rMVViw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.16': + resolution: {integrity: sha512-n89ibATwnLEg0ZdZmUds5bq8AfBAdoYEDpqP3uzPLaRuGelsKlIvCYSNNvfgGLi8NaHPNNhs1HjJZYbqkW9b+g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.972.17': + resolution: {integrity: sha512-wGtte+48xnhnhHMl/MsxzacBPs5A+7JJedjiP452IkHY7vsbYKcvQBqFye8LwdTJVeHtBHv+JFeTscnwepoWGg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.17': + resolution: {integrity: sha512-8aiVJh6fTdl8gcyL+sVNcNwTtWpmoFa1Sh7xlj6Z7L/cZ/tYMEBHq44wTYG8Kt0z/PpGNopD89nbj3FHl9QmTA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-providers@3.1004.0': + resolution: {integrity: sha512-THsua88i7DrPoO8WCIWLPWb8706s2ytl2ej+WB9sv39VPCJNc7YwGtTA51reziyzlLnJUGHkI+krp0oTHEGaBw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-host-header@3.972.7': + resolution: {integrity: sha512-aHQZgztBFEpDU1BB00VWCIIm85JjGjQW1OG9+98BdmaOpguJvzmXBGbnAiYcciCd+IS4e9BEq664lhzGnWJHgQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-logger@3.972.7': + resolution: {integrity: sha512-LXhiWlWb26txCU1vcI9PneESSeRp/RYY/McuM4SpdrimQR5NgwaPb4VJCadVeuGWgh6QmqZ6rAKSoL1ob16W6w==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-recursion-detection@3.972.7': + resolution: {integrity: sha512-l2VQdcBcYLzIzykCHtXlbpiVCZ94/xniLIkAj0jpnpjY4xlgZx7f56Ypn+uV1y3gG0tNVytJqo3K9bfMFee7SQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-user-agent@3.972.19': + resolution: {integrity: sha512-Km90fcXt3W/iqujHzuM6IaDkYCj73gsYufcuWXApWdzoTy6KGk8fnchAjePMARU0xegIR3K4N3yIo1vy7OVe8A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/nested-clients@3.996.7': + resolution: {integrity: sha512-MlGWA8uPaOs5AiTZ5JLM4uuWDm9EEAnm9cqwvqQIc6kEgel/8s1BaOWm9QgUcfc9K8qd7KkC3n43yDbeXOA2tg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/region-config-resolver@3.972.7': + resolution: {integrity: sha512-/Ev/6AI8bvt4HAAptzSjThGUMjcWaX3GX8oERkB0F0F9x2dLSBdgFDiyrRz3i0u0ZFZFQ1b28is4QhyqXTUsVA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1004.0': + resolution: {integrity: sha512-j9BwZZId9sFp+4GPhf6KrwO8Tben2sXibZA8D1vv2I1zBdvkUHcBA2g4pkqIpTRalMTLC0NPkBPX0gERxfy/iA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.973.5': + resolution: {integrity: sha512-hl7BGwDCWsjH8NkZfx+HgS7H2LyM2lTMAI7ba9c8O0KqdBLTdNJivsHpqjg9rNlAlPyREb6DeDRXUl0s8uFdmQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-endpoints@3.996.4': + resolution: {integrity: sha512-Hek90FBmd4joCFj+Vc98KLJh73Zqj3s2W56gjAcTkrNLMDI5nIFkG9YpfcJiVI1YlE2Ne1uOQNe+IgQ/Vz2XRA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-locate-window@3.965.5': + resolution: {integrity: sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-user-agent-browser@3.972.7': + resolution: {integrity: sha512-7SJVuvhKhMF/BkNS1n0QAJYgvEwYbK2QLKBrzDiwQGiTRU6Yf1f3nehTzm/l21xdAOtWSfp2uWSddPnP2ZtsVw==} + + '@aws-sdk/util-user-agent-node@3.973.4': + resolution: {integrity: sha512-uqKeLqZ9D3nQjH7HGIERNXK9qnSpUK08l4MlJ5/NZqSSdeJsVANYp437EM9sEzwU28c2xfj2V6qlkqzsgtKs6Q==} + engines: {node: '>=20.0.0'} + peerDependencies: + aws-crt: '>=1.0.0' + peerDependenciesMeta: + aws-crt: + optional: true + + '@aws-sdk/xml-builder@3.972.10': + resolution: {integrity: sha512-OnejAIVD+CxzyAUrVic7lG+3QRltyja9LoNqCE/1YVs8ichoTbJlVSaZ9iSMcnHLyzrSNtvaOGjSDRP+d/ouFA==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.2.3': + resolution: {integrity: sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==} + engines: {node: '>=18.0.0'} + '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} @@ -676,10 +834,37 @@ packages: '@types/node': optional: true + '@inquirer/core@11.1.5': + resolution: {integrity: sha512-QQPAX+lka8GyLcZ7u7Nb1h6q72iZ/oy0blilC3IB2nSt1Qqxp7akt94Jqhi/DzARuN3Eo9QwJRvtl4tmVe4T5A==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@inquirer/figures@2.0.3': resolution: {integrity: sha512-y09iGt3JKoOCBQ3w4YrSJdokcD8ciSlMIWsD+auPu+OZpfxLuyz+gICAQ6GCBOmJJt4KEQGHuZSVff2jiNOy7g==} engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + '@inquirer/input@5.0.8': + resolution: {integrity: sha512-p0IJslw0AmedLEkOU+yrEX3Aj2RTpQq7ZOf8nc1DIhjzaxRWrrgeuE5Kyh39fVRgtcACaMXx/9WNo8+GjgBOfw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@5.0.8': + resolution: {integrity: sha512-zt1sF4lYLdvPqvmvHdmjOzuUUjuCQ897pdUCO8RbXMUDKXJTTyOQgtn23le+jwcb+MpHl3VAFvzIdxRAf6aPlA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@inquirer/search@4.1.3': resolution: {integrity: sha512-6BE8MqVMakEiLDRtrwj9fbx6AYhuj7McW3GOkOoEiQ5Qkh6v6f5HCoYNqSRE4j6nT+u+73518iUQPE+mZYlAjA==} engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} @@ -689,6 +874,15 @@ packages: '@types/node': optional: true + '@inquirer/select@5.1.0': + resolution: {integrity: sha512-OyYbKnchS1u+zRe14LpYrN8S0wH1vD0p2yKISvSsJdH2TpI87fh4eZdWnpdbrGauCRWDph3NwxRmM4Pcm/hx1Q==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@inquirer/type@4.0.3': resolution: {integrity: sha512-cKZN7qcXOpj1h+1eTTcGDVLaBIHNMT1Rz9JqJP5MnEJ0JhgVWllx7H/tahUp5YEK1qaByH2Itb8wLG/iScD5kw==} engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} @@ -1359,6 +1553,182 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} + '@smithy/abort-controller@4.2.11': + resolution: {integrity: sha512-Hj4WoYWMJnSpM6/kchsm4bUNTL9XiSyhvoMb2KIq4VJzyDt7JpGHUZHkVNPZVC7YE1tf8tPeVauxpFBKGW4/KQ==} + engines: {node: '>=18.0.0'} + + '@smithy/config-resolver@4.4.10': + resolution: {integrity: sha512-IRTkd6ps0ru+lTWnfnsbXzW80A8Od8p3pYiZnW98K2Hb20rqfsX7VTlfUwhrcOeSSy68Gn9WBofwPuw3e5CCsg==} + engines: {node: '>=18.0.0'} + + '@smithy/core@3.23.9': + resolution: {integrity: sha512-1Vcut4LEL9HZsdpI0vFiRYIsaoPwZLjAxnVQDUMQK8beMS+EYPLDQCXtbzfxmM5GzSgjfe2Q9M7WaXwIMQllyQ==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.2.11': + resolution: {integrity: sha512-lBXrS6ku0kTj3xLmsJW0WwqWbGQ6ueooYyp/1L9lkyT0M02C+DWwYwc5aTyXFbRaK38ojALxNixg+LxKSHZc0g==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-codec@4.2.11': + resolution: {integrity: sha512-Sf39Ml0iVX+ba/bgMPxaXWAAFmHqYLTmbjAPfLPLY8CrYkRDEqZdUsKC1OwVMCdJXfAt0v4j49GIJ8DoSYAe6w==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.3.13': + resolution: {integrity: sha512-U2Hcfl2s3XaYjikN9cT4mPu8ybDbImV3baXR0PkVlC0TTx808bRP3FaPGAzPtB8OByI+JqJ1kyS+7GEgae7+qQ==} + engines: {node: '>=18.0.0'} + + '@smithy/hash-node@4.2.11': + resolution: {integrity: sha512-T+p1pNynRkydpdL015ruIoyPSRw9e/SQOWmSAMmmprfswMrd5Ow5igOWNVlvyVFZlxXqGmyH3NQwfwy8r5Jx0A==} + engines: {node: '>=18.0.0'} + + '@smithy/invalid-dependency@4.2.11': + resolution: {integrity: sha512-cGNMrgykRmddrNhYy1yBdrp5GwIgEkniS7k9O1VLB38yxQtlvrxpZtUVvo6T4cKpeZsriukBuuxfJcdZQc/f/g==} + engines: {node: '>=18.0.0'} + + '@smithy/is-array-buffer@2.2.0': + resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} + engines: {node: '>=14.0.0'} + + '@smithy/is-array-buffer@4.2.2': + resolution: {integrity: sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-content-length@4.2.11': + resolution: {integrity: sha512-UvIfKYAKhCzr4p6jFevPlKhQwyQwlJ6IeKLDhmV1PlYfcW3RL4ROjNEDtSik4NYMi9kDkH7eSwyTP3vNJ/u/Dw==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-endpoint@4.4.23': + resolution: {integrity: sha512-UEFIejZy54T1EJn2aWJ45voB7RP2T+IRzUqocIdM6GFFa5ClZncakYJfcYnoXt3UsQrZZ9ZRauGm77l9UCbBLw==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-retry@4.4.40': + resolution: {integrity: sha512-YhEMakG1Ae57FajERdHNZ4ShOPIY7DsgV+ZoAxo/5BT0KIe+f6DDU2rtIymNNFIj22NJfeeI6LWIifrwM0f+rA==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-serde@4.2.12': + resolution: {integrity: sha512-W9g1bOLui7Xn5FABRVS0o3rXL0gfN37d/8I/W7i0N7oxjx9QecUmXEMSUMADTODwdtka9cN43t5BI2CodLJpng==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-stack@4.2.11': + resolution: {integrity: sha512-s+eenEPW6RgliDk2IhjD2hWOxIx1NKrOHxEwNUaUXxYBxIyCcDfNULZ2Mu15E3kwcJWBedTET/kEASPV1A1Akg==} + engines: {node: '>=18.0.0'} + + '@smithy/node-config-provider@4.3.11': + resolution: {integrity: sha512-xD17eE7kaLgBBGf5CZQ58hh2YmwK1Z0O8YhffwB/De2jsL0U3JklmhVYJ9Uf37OtUDLF2gsW40Xwwag9U869Gg==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.4.14': + resolution: {integrity: sha512-DamSqaU8nuk0xTJDrYnRzZndHwwRnyj/n/+RqGGCcBKB4qrQem0mSDiWdupaNWdwxzyMU91qxDmHOCazfhtO3A==} + engines: {node: '>=18.0.0'} + + '@smithy/property-provider@4.2.11': + resolution: {integrity: sha512-14T1V64o6/ndyrnl1ze1ZhyLzIeYNN47oF/QU6P5m82AEtyOkMJTb0gO1dPubYjyyKuPD6OSVMPDKe+zioOnCg==} + engines: {node: '>=18.0.0'} + + '@smithy/protocol-http@5.3.11': + resolution: {integrity: sha512-hI+barOVDJBkNt4y0L2mu3Ugc0w7+BpJ2CZuLwXtSltGAAwCb3IvnalGlbDV/UCS6a9ZuT3+exd1WxNdLb5IlQ==} + engines: {node: '>=18.0.0'} + + '@smithy/querystring-builder@4.2.11': + resolution: {integrity: sha512-7spdikrYiljpket6u0up2Ck2mxhy7dZ0+TDd+S53Dg2DHd6wg+YNJrTCHiLdgZmEXZKI7LJZcwL3721ZRDFiqA==} + engines: {node: '>=18.0.0'} + + '@smithy/querystring-parser@4.2.11': + resolution: {integrity: sha512-nE3IRNjDltvGcoThD2abTozI1dkSy8aX+a2N1Rs55en5UsdyyIXgGEmevUL3okZFoJC77JgRGe99xYohhsjivQ==} + engines: {node: '>=18.0.0'} + + '@smithy/service-error-classification@4.2.11': + resolution: {integrity: sha512-HkMFJZJUhzU3HvND1+Yw/kYWXp4RPDLBWLcK1n+Vqw8xn4y2YiBhdww8IxhkQjP/QlZun5bwm3vcHc8AqIU3zw==} + engines: {node: '>=18.0.0'} + + '@smithy/shared-ini-file-loader@4.4.6': + resolution: {integrity: sha512-IB/M5I8G0EeXZTHsAxpx51tMQ5R719F3aq+fjEB6VtNcCHDc0ajFDIGDZw+FW9GxtEkgTduiPpjveJdA/CX7sw==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.3.11': + resolution: {integrity: sha512-V1L6N9aKOBAN4wEHLyqjLBnAz13mtILU0SeDrjOaIZEeN6IFa6DxwRt1NNpOdmSpQUfkBj0qeD3m6P77uzMhgQ==} + engines: {node: '>=18.0.0'} + + '@smithy/smithy-client@4.12.3': + resolution: {integrity: sha512-7k4UxjSpHmPN2AxVhvIazRSzFQjWnud3sOsXcFStzagww17j1cFQYqTSiQ8xuYK3vKLR1Ni8FzuT3VlKr3xCNw==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.13.0': + resolution: {integrity: sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw==} + engines: {node: '>=18.0.0'} + + '@smithy/url-parser@4.2.11': + resolution: {integrity: sha512-oTAGGHo8ZYc5VZsBREzuf5lf2pAurJQsccMusVZ85wDkX66ojEc/XauiGjzCj50A61ObFTPe6d7Pyt6UBYaing==} + engines: {node: '>=18.0.0'} + + '@smithy/util-base64@4.3.2': + resolution: {integrity: sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-body-length-browser@4.2.2': + resolution: {integrity: sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-body-length-node@4.2.3': + resolution: {integrity: sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==} + engines: {node: '>=18.0.0'} + + '@smithy/util-buffer-from@2.2.0': + resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} + engines: {node: '>=14.0.0'} + + '@smithy/util-buffer-from@4.2.2': + resolution: {integrity: sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==} + engines: {node: '>=18.0.0'} + + '@smithy/util-config-provider@4.2.2': + resolution: {integrity: sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-defaults-mode-browser@4.3.39': + resolution: {integrity: sha512-ui7/Ho/+VHqS7Km2wBw4/Ab4RktoiSshgcgpJzC4keFPs6tLJS4IQwbeahxQS3E/w98uq6E1mirCH/id9xIXeQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-defaults-mode-node@4.2.42': + resolution: {integrity: sha512-QDA84CWNe8Akpj15ofLO+1N3Rfg8qa2K5uX0y6HnOp4AnRYRgWrKx/xzbYNbVF9ZsyJUYOfcoaN3y93wA/QJ2A==} + engines: {node: '>=18.0.0'} + + '@smithy/util-endpoints@3.3.2': + resolution: {integrity: sha512-+4HFLpE5u29AbFlTdlKIT7jfOzZ8PDYZKTb3e+AgLz986OYwqTourQ5H+jg79/66DB69Un1+qKecLnkZdAsYcA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-hex-encoding@4.2.2': + resolution: {integrity: sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-middleware@4.2.11': + resolution: {integrity: sha512-r3dtF9F+TpSZUxpOVVtPfk09Rlo4lT6ORBqEvX3IBT6SkQAdDSVKR5GcfmZbtl7WKhKnmb3wbDTQ6ibR2XHClw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-retry@4.2.11': + resolution: {integrity: sha512-XSZULmL5x6aCTTii59wJqKsY1l3eMIAomRAccW7Tzh9r8s7T/7rdo03oektuH5jeYRlJMPcNP92EuRDvk9aXbw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-stream@4.5.17': + resolution: {integrity: sha512-793BYZ4h2JAQkNHcEnyFxDTcZbm9bVybD0UV/LEWmZ5bkTms7JqjfrLMi2Qy0E5WFcCzLwCAPgcvcvxoeALbAQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-uri-escape@4.2.2': + resolution: {integrity: sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-utf8@2.3.0': + resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} + engines: {node: '>=14.0.0'} + + '@smithy/util-utf8@4.2.2': + resolution: {integrity: sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==} + engines: {node: '>=18.0.0'} + + '@smithy/uuid@1.1.2': + resolution: {integrity: sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==} + engines: {node: '>=18.0.0'} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -1705,6 +2075,9 @@ packages: avvio@9.2.0: resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==} + aws4fetch@1.0.20: + resolution: {integrity: sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==} + bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} @@ -1742,6 +2115,9 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + brace-expansion@5.0.2: resolution: {integrity: sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==} engines: {node: 20 || >=22} @@ -2037,10 +2413,17 @@ packages: fast-wrap-ansi@0.2.0: resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==} + fast-xml-builder@1.0.0: + resolution: {integrity: sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==} + fast-xml-parser@5.3.7: resolution: {integrity: sha512-JzVLro9NQv92pOM/jTCR6mHlJh2FGwtomH8ZQjhFj/R29P2Fnj38OgPJVtcvYw6SuKClhgYuwUZf5b3rd8u2mA==} hasBin: true + fast-xml-parser@5.4.1: + resolution: {integrity: sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==} + hasBin: true + fastify-plugin@5.1.0: resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} @@ -3644,6 +4027,22 @@ packages: snapshots: + '@ai-sdk/amazon-bedrock@4.0.77(zod@4.3.6)': + dependencies: + '@ai-sdk/anthropic': 3.0.58(zod@4.3.6) + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.19(zod@4.3.6) + '@smithy/eventstream-codec': 4.2.11 + '@smithy/util-utf8': 4.2.2 + aws4fetch: 1.0.20 + zod: 4.3.6 + + '@ai-sdk/anthropic@3.0.58(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.19(zod@4.3.6) + zod: 4.3.6 + '@ai-sdk/gateway@3.0.53(zod@4.3.6)': dependencies: '@ai-sdk/provider': 3.0.8 @@ -3664,6 +4063,13 @@ snapshots: eventsource-parser: 3.0.6 zod: 4.3.6 + '@ai-sdk/provider-utils@4.0.19(zod@4.3.6)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.0.6 + zod: 4.3.6 + '@ai-sdk/provider@2.0.1': dependencies: json-schema: 0.4.0 @@ -3674,6 +4080,372 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@aws-crypto/crc32@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.5 + tslib: 2.8.1 + + '@aws-crypto/sha256-browser@5.2.0': + dependencies: + '@aws-crypto/sha256-js': 5.2.0 + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.5 + '@aws-sdk/util-locate-window': 3.965.5 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-js@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.5 + tslib: 2.8.1 + + '@aws-crypto/supports-web-crypto@5.2.0': + dependencies: + tslib: 2.8.1 + + '@aws-crypto/util@5.2.0': + dependencies: + '@aws-sdk/types': 3.973.5 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-sdk/client-cognito-identity@3.1004.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.18 + '@aws-sdk/credential-provider-node': 3.972.18 + '@aws-sdk/middleware-host-header': 3.972.7 + '@aws-sdk/middleware-logger': 3.972.7 + '@aws-sdk/middleware-recursion-detection': 3.972.7 + '@aws-sdk/middleware-user-agent': 3.972.19 + '@aws-sdk/region-config-resolver': 3.972.7 + '@aws-sdk/types': 3.973.5 + '@aws-sdk/util-endpoints': 3.996.4 + '@aws-sdk/util-user-agent-browser': 3.972.7 + '@aws-sdk/util-user-agent-node': 3.973.4 + '@smithy/config-resolver': 4.4.10 + '@smithy/core': 3.23.9 + '@smithy/fetch-http-handler': 5.3.13 + '@smithy/hash-node': 4.2.11 + '@smithy/invalid-dependency': 4.2.11 + '@smithy/middleware-content-length': 4.2.11 + '@smithy/middleware-endpoint': 4.4.23 + '@smithy/middleware-retry': 4.4.40 + '@smithy/middleware-serde': 4.2.12 + '@smithy/middleware-stack': 4.2.11 + '@smithy/node-config-provider': 4.3.11 + '@smithy/node-http-handler': 4.4.14 + '@smithy/protocol-http': 5.3.11 + '@smithy/smithy-client': 4.12.3 + '@smithy/types': 4.13.0 + '@smithy/url-parser': 4.2.11 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-body-length-node': 4.2.3 + '@smithy/util-defaults-mode-browser': 4.3.39 + '@smithy/util-defaults-mode-node': 4.2.42 + '@smithy/util-endpoints': 3.3.2 + '@smithy/util-middleware': 4.2.11 + '@smithy/util-retry': 4.2.11 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/core@3.973.18': + dependencies: + '@aws-sdk/types': 3.973.5 + '@aws-sdk/xml-builder': 3.972.10 + '@smithy/core': 3.23.9 + '@smithy/node-config-provider': 4.3.11 + '@smithy/property-provider': 4.2.11 + '@smithy/protocol-http': 5.3.11 + '@smithy/signature-v4': 5.3.11 + '@smithy/smithy-client': 4.12.3 + '@smithy/types': 4.13.0 + '@smithy/util-base64': 4.3.2 + '@smithy/util-middleware': 4.2.11 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-cognito-identity@3.972.10': + dependencies: + '@aws-sdk/nested-clients': 3.996.7 + '@aws-sdk/types': 3.973.5 + '@smithy/property-provider': 4.2.11 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-env@3.972.16': + dependencies: + '@aws-sdk/core': 3.973.18 + '@aws-sdk/types': 3.973.5 + '@smithy/property-provider': 4.2.11 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.18': + dependencies: + '@aws-sdk/core': 3.973.18 + '@aws-sdk/types': 3.973.5 + '@smithy/fetch-http-handler': 5.3.13 + '@smithy/node-http-handler': 4.4.14 + '@smithy/property-provider': 4.2.11 + '@smithy/protocol-http': 5.3.11 + '@smithy/smithy-client': 4.12.3 + '@smithy/types': 4.13.0 + '@smithy/util-stream': 4.5.17 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.972.17': + dependencies: + '@aws-sdk/core': 3.973.18 + '@aws-sdk/credential-provider-env': 3.972.16 + '@aws-sdk/credential-provider-http': 3.972.18 + '@aws-sdk/credential-provider-login': 3.972.17 + '@aws-sdk/credential-provider-process': 3.972.16 + '@aws-sdk/credential-provider-sso': 3.972.17 + '@aws-sdk/credential-provider-web-identity': 3.972.17 + '@aws-sdk/nested-clients': 3.996.7 + '@aws-sdk/types': 3.973.5 + '@smithy/credential-provider-imds': 4.2.11 + '@smithy/property-provider': 4.2.11 + '@smithy/shared-ini-file-loader': 4.4.6 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-login@3.972.17': + dependencies: + '@aws-sdk/core': 3.973.18 + '@aws-sdk/nested-clients': 3.996.7 + '@aws-sdk/types': 3.973.5 + '@smithy/property-provider': 4.2.11 + '@smithy/protocol-http': 5.3.11 + '@smithy/shared-ini-file-loader': 4.4.6 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-node@3.972.18': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.16 + '@aws-sdk/credential-provider-http': 3.972.18 + '@aws-sdk/credential-provider-ini': 3.972.17 + '@aws-sdk/credential-provider-process': 3.972.16 + '@aws-sdk/credential-provider-sso': 3.972.17 + '@aws-sdk/credential-provider-web-identity': 3.972.17 + '@aws-sdk/types': 3.973.5 + '@smithy/credential-provider-imds': 4.2.11 + '@smithy/property-provider': 4.2.11 + '@smithy/shared-ini-file-loader': 4.4.6 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-process@3.972.16': + dependencies: + '@aws-sdk/core': 3.973.18 + '@aws-sdk/types': 3.973.5 + '@smithy/property-provider': 4.2.11 + '@smithy/shared-ini-file-loader': 4.4.6 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.972.17': + dependencies: + '@aws-sdk/core': 3.973.18 + '@aws-sdk/nested-clients': 3.996.7 + '@aws-sdk/token-providers': 3.1004.0 + '@aws-sdk/types': 3.973.5 + '@smithy/property-provider': 4.2.11 + '@smithy/shared-ini-file-loader': 4.4.6 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-web-identity@3.972.17': + dependencies: + '@aws-sdk/core': 3.973.18 + '@aws-sdk/nested-clients': 3.996.7 + '@aws-sdk/types': 3.973.5 + '@smithy/property-provider': 4.2.11 + '@smithy/shared-ini-file-loader': 4.4.6 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-providers@3.1004.0': + dependencies: + '@aws-sdk/client-cognito-identity': 3.1004.0 + '@aws-sdk/core': 3.973.18 + '@aws-sdk/credential-provider-cognito-identity': 3.972.10 + '@aws-sdk/credential-provider-env': 3.972.16 + '@aws-sdk/credential-provider-http': 3.972.18 + '@aws-sdk/credential-provider-ini': 3.972.17 + '@aws-sdk/credential-provider-login': 3.972.17 + '@aws-sdk/credential-provider-node': 3.972.18 + '@aws-sdk/credential-provider-process': 3.972.16 + '@aws-sdk/credential-provider-sso': 3.972.17 + '@aws-sdk/credential-provider-web-identity': 3.972.17 + '@aws-sdk/nested-clients': 3.996.7 + '@aws-sdk/types': 3.973.5 + '@smithy/config-resolver': 4.4.10 + '@smithy/core': 3.23.9 + '@smithy/credential-provider-imds': 4.2.11 + '@smithy/node-config-provider': 4.3.11 + '@smithy/property-provider': 4.2.11 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/middleware-host-header@3.972.7': + dependencies: + '@aws-sdk/types': 3.973.5 + '@smithy/protocol-http': 5.3.11 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-logger@3.972.7': + dependencies: + '@aws-sdk/types': 3.973.5 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-recursion-detection@3.972.7': + dependencies: + '@aws-sdk/types': 3.973.5 + '@aws/lambda-invoke-store': 0.2.3 + '@smithy/protocol-http': 5.3.11 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-user-agent@3.972.19': + dependencies: + '@aws-sdk/core': 3.973.18 + '@aws-sdk/types': 3.973.5 + '@aws-sdk/util-endpoints': 3.996.4 + '@smithy/core': 3.23.9 + '@smithy/protocol-http': 5.3.11 + '@smithy/types': 4.13.0 + '@smithy/util-retry': 4.2.11 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.996.7': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.18 + '@aws-sdk/middleware-host-header': 3.972.7 + '@aws-sdk/middleware-logger': 3.972.7 + '@aws-sdk/middleware-recursion-detection': 3.972.7 + '@aws-sdk/middleware-user-agent': 3.972.19 + '@aws-sdk/region-config-resolver': 3.972.7 + '@aws-sdk/types': 3.973.5 + '@aws-sdk/util-endpoints': 3.996.4 + '@aws-sdk/util-user-agent-browser': 3.972.7 + '@aws-sdk/util-user-agent-node': 3.973.4 + '@smithy/config-resolver': 4.4.10 + '@smithy/core': 3.23.9 + '@smithy/fetch-http-handler': 5.3.13 + '@smithy/hash-node': 4.2.11 + '@smithy/invalid-dependency': 4.2.11 + '@smithy/middleware-content-length': 4.2.11 + '@smithy/middleware-endpoint': 4.4.23 + '@smithy/middleware-retry': 4.4.40 + '@smithy/middleware-serde': 4.2.12 + '@smithy/middleware-stack': 4.2.11 + '@smithy/node-config-provider': 4.3.11 + '@smithy/node-http-handler': 4.4.14 + '@smithy/protocol-http': 5.3.11 + '@smithy/smithy-client': 4.12.3 + '@smithy/types': 4.13.0 + '@smithy/url-parser': 4.2.11 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-body-length-node': 4.2.3 + '@smithy/util-defaults-mode-browser': 4.3.39 + '@smithy/util-defaults-mode-node': 4.2.42 + '@smithy/util-endpoints': 3.3.2 + '@smithy/util-middleware': 4.2.11 + '@smithy/util-retry': 4.2.11 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/region-config-resolver@3.972.7': + dependencies: + '@aws-sdk/types': 3.973.5 + '@smithy/config-resolver': 4.4.10 + '@smithy/node-config-provider': 4.3.11 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1004.0': + dependencies: + '@aws-sdk/core': 3.973.18 + '@aws-sdk/nested-clients': 3.996.7 + '@aws-sdk/types': 3.973.5 + '@smithy/property-provider': 4.2.11 + '@smithy/shared-ini-file-loader': 4.4.6 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/types@3.973.5': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@aws-sdk/util-endpoints@3.996.4': + dependencies: + '@aws-sdk/types': 3.973.5 + '@smithy/types': 4.13.0 + '@smithy/url-parser': 4.2.11 + '@smithy/util-endpoints': 3.3.2 + tslib: 2.8.1 + + '@aws-sdk/util-locate-window@3.965.5': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-browser@3.972.7': + dependencies: + '@aws-sdk/types': 3.973.5 + '@smithy/types': 4.13.0 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-node@3.973.4': + dependencies: + '@aws-sdk/middleware-user-agent': 3.972.19 + '@aws-sdk/types': 3.973.5 + '@smithy/node-config-provider': 4.3.11 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.10': + dependencies: + '@smithy/types': 4.13.0 + fast-xml-parser: 5.4.1 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.2.3': {} + '@babel/helper-string-parser@7.27.1': {} '@babel/helper-validator-identifier@7.28.5': {} @@ -4038,8 +4810,35 @@ snapshots: optionalDependencies: '@types/node': 25.2.3 + '@inquirer/core@11.1.5(@types/node@25.2.3)': + dependencies: + '@inquirer/ansi': 2.0.3 + '@inquirer/figures': 2.0.3 + '@inquirer/type': 4.0.3(@types/node@25.2.3) + cli-width: 4.1.0 + fast-wrap-ansi: 0.2.0 + mute-stream: 3.0.0 + signal-exit: 4.1.0 + optionalDependencies: + '@types/node': 25.2.3 + '@inquirer/figures@2.0.3': {} + '@inquirer/input@5.0.8(@types/node@25.2.3)': + dependencies: + '@inquirer/core': 11.1.5(@types/node@25.2.3) + '@inquirer/type': 4.0.3(@types/node@25.2.3) + optionalDependencies: + '@types/node': 25.2.3 + + '@inquirer/password@5.0.8(@types/node@25.2.3)': + dependencies: + '@inquirer/ansi': 2.0.3 + '@inquirer/core': 11.1.5(@types/node@25.2.3) + '@inquirer/type': 4.0.3(@types/node@25.2.3) + optionalDependencies: + '@types/node': 25.2.3 + '@inquirer/search@4.1.3(@types/node@25.2.3)': dependencies: '@inquirer/core': 11.1.4(@types/node@25.2.3) @@ -4048,6 +4847,15 @@ snapshots: optionalDependencies: '@types/node': 25.2.3 + '@inquirer/select@5.1.0(@types/node@25.2.3)': + dependencies: + '@inquirer/ansi': 2.0.3 + '@inquirer/core': 11.1.5(@types/node@25.2.3) + '@inquirer/figures': 2.0.3 + '@inquirer/type': 4.0.3(@types/node@25.2.3) + optionalDependencies: + '@types/node': 25.2.3 + '@inquirer/type@4.0.3(@types/node@25.2.3)': optionalDependencies: '@types/node': 25.2.3 @@ -4646,6 +5454,287 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} + '@smithy/abort-controller@4.2.11': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@smithy/config-resolver@4.4.10': + dependencies: + '@smithy/node-config-provider': 4.3.11 + '@smithy/types': 4.13.0 + '@smithy/util-config-provider': 4.2.2 + '@smithy/util-endpoints': 3.3.2 + '@smithy/util-middleware': 4.2.11 + tslib: 2.8.1 + + '@smithy/core@3.23.9': + dependencies: + '@smithy/middleware-serde': 4.2.12 + '@smithy/protocol-http': 5.3.11 + '@smithy/types': 4.13.0 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-middleware': 4.2.11 + '@smithy/util-stream': 4.5.17 + '@smithy/util-utf8': 4.2.2 + '@smithy/uuid': 1.1.2 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.2.11': + dependencies: + '@smithy/node-config-provider': 4.3.11 + '@smithy/property-provider': 4.2.11 + '@smithy/types': 4.13.0 + '@smithy/url-parser': 4.2.11 + tslib: 2.8.1 + + '@smithy/eventstream-codec@4.2.11': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@smithy/types': 4.13.0 + '@smithy/util-hex-encoding': 4.2.2 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.3.13': + dependencies: + '@smithy/protocol-http': 5.3.11 + '@smithy/querystring-builder': 4.2.11 + '@smithy/types': 4.13.0 + '@smithy/util-base64': 4.3.2 + tslib: 2.8.1 + + '@smithy/hash-node@4.2.11': + dependencies: + '@smithy/types': 4.13.0 + '@smithy/util-buffer-from': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/invalid-dependency@4.2.11': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@smithy/is-array-buffer@2.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/is-array-buffer@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/middleware-content-length@4.2.11': + dependencies: + '@smithy/protocol-http': 5.3.11 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@smithy/middleware-endpoint@4.4.23': + dependencies: + '@smithy/core': 3.23.9 + '@smithy/middleware-serde': 4.2.12 + '@smithy/node-config-provider': 4.3.11 + '@smithy/shared-ini-file-loader': 4.4.6 + '@smithy/types': 4.13.0 + '@smithy/url-parser': 4.2.11 + '@smithy/util-middleware': 4.2.11 + tslib: 2.8.1 + + '@smithy/middleware-retry@4.4.40': + dependencies: + '@smithy/node-config-provider': 4.3.11 + '@smithy/protocol-http': 5.3.11 + '@smithy/service-error-classification': 4.2.11 + '@smithy/smithy-client': 4.12.3 + '@smithy/types': 4.13.0 + '@smithy/util-middleware': 4.2.11 + '@smithy/util-retry': 4.2.11 + '@smithy/uuid': 1.1.2 + tslib: 2.8.1 + + '@smithy/middleware-serde@4.2.12': + dependencies: + '@smithy/protocol-http': 5.3.11 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@smithy/middleware-stack@4.2.11': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@smithy/node-config-provider@4.3.11': + dependencies: + '@smithy/property-provider': 4.2.11 + '@smithy/shared-ini-file-loader': 4.4.6 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.4.14': + dependencies: + '@smithy/abort-controller': 4.2.11 + '@smithy/protocol-http': 5.3.11 + '@smithy/querystring-builder': 4.2.11 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@smithy/property-provider@4.2.11': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@smithy/protocol-http@5.3.11': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@smithy/querystring-builder@4.2.11': + dependencies: + '@smithy/types': 4.13.0 + '@smithy/util-uri-escape': 4.2.2 + tslib: 2.8.1 + + '@smithy/querystring-parser@4.2.11': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@smithy/service-error-classification@4.2.11': + dependencies: + '@smithy/types': 4.13.0 + + '@smithy/shared-ini-file-loader@4.4.6': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@smithy/signature-v4@5.3.11': + dependencies: + '@smithy/is-array-buffer': 4.2.2 + '@smithy/protocol-http': 5.3.11 + '@smithy/types': 4.13.0 + '@smithy/util-hex-encoding': 4.2.2 + '@smithy/util-middleware': 4.2.11 + '@smithy/util-uri-escape': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/smithy-client@4.12.3': + dependencies: + '@smithy/core': 3.23.9 + '@smithy/middleware-endpoint': 4.4.23 + '@smithy/middleware-stack': 4.2.11 + '@smithy/protocol-http': 5.3.11 + '@smithy/types': 4.13.0 + '@smithy/util-stream': 4.5.17 + tslib: 2.8.1 + + '@smithy/types@4.13.0': + dependencies: + tslib: 2.8.1 + + '@smithy/url-parser@4.2.11': + dependencies: + '@smithy/querystring-parser': 4.2.11 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@smithy/util-base64@4.3.2': + dependencies: + '@smithy/util-buffer-from': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/util-body-length-browser@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/util-body-length-node@4.2.3': + dependencies: + tslib: 2.8.1 + + '@smithy/util-buffer-from@2.2.0': + dependencies: + '@smithy/is-array-buffer': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-buffer-from@4.2.2': + dependencies: + '@smithy/is-array-buffer': 4.2.2 + tslib: 2.8.1 + + '@smithy/util-config-provider@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/util-defaults-mode-browser@4.3.39': + dependencies: + '@smithy/property-provider': 4.2.11 + '@smithy/smithy-client': 4.12.3 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@smithy/util-defaults-mode-node@4.2.42': + dependencies: + '@smithy/config-resolver': 4.4.10 + '@smithy/credential-provider-imds': 4.2.11 + '@smithy/node-config-provider': 4.3.11 + '@smithy/property-provider': 4.2.11 + '@smithy/smithy-client': 4.12.3 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@smithy/util-endpoints@3.3.2': + dependencies: + '@smithy/node-config-provider': 4.3.11 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@smithy/util-hex-encoding@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/util-middleware@4.2.11': + dependencies: + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@smithy/util-retry@4.2.11': + dependencies: + '@smithy/service-error-classification': 4.2.11 + '@smithy/types': 4.13.0 + tslib: 2.8.1 + + '@smithy/util-stream@4.5.17': + dependencies: + '@smithy/fetch-http-handler': 5.3.13 + '@smithy/node-http-handler': 4.4.14 + '@smithy/types': 4.13.0 + '@smithy/util-base64': 4.3.2 + '@smithy/util-buffer-from': 4.2.2 + '@smithy/util-hex-encoding': 4.2.2 + '@smithy/util-utf8': 4.2.2 + tslib: 2.8.1 + + '@smithy/util-uri-escape@4.2.2': + dependencies: + tslib: 2.8.1 + + '@smithy/util-utf8@2.3.0': + dependencies: + '@smithy/util-buffer-from': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-utf8@4.2.2': + dependencies: + '@smithy/util-buffer-from': 4.2.2 + tslib: 2.8.1 + + '@smithy/uuid@1.1.2': + dependencies: + tslib: 2.8.1 + '@standard-schema/spec@1.1.0': {} '@stricli/auto-complete@1.2.6': @@ -4953,6 +6042,8 @@ snapshots: '@fastify/error': 4.2.0 fastq: 1.20.1 + aws4fetch@1.0.20: {} + bail@2.0.2: {} balanced-match@4.0.3: {} @@ -4985,6 +6076,8 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + bowser@2.14.1: {} + brace-expansion@5.0.2: dependencies: balanced-match: 4.0.3 @@ -5305,10 +6398,17 @@ snapshots: dependencies: fast-string-width: 3.0.2 + fast-xml-builder@1.0.0: {} + fast-xml-parser@5.3.7: dependencies: strnum: 2.1.2 + fast-xml-parser@5.4.1: + dependencies: + fast-xml-builder: 1.0.0 + strnum: 2.1.2 + fastify-plugin@5.1.0: {} fastify@5.8.2: From 943aef57b0fc547352b6e60ff61ad227dea32308 Mon Sep 17 00:00:00 2001 From: Daniel Wise Date: Sun, 8 Mar 2026 15:22:09 -0700 Subject: [PATCH 2/2] fix(ai-setup): narrow model choice provider type --- packages/cli/src/commands/ai-setup.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/cli/src/commands/ai-setup.ts b/packages/cli/src/commands/ai-setup.ts index 6db665c..a29804c 100644 --- a/packages/cli/src/commands/ai-setup.ts +++ b/packages/cli/src/commands/ai-setup.ts @@ -8,7 +8,6 @@ import { } from './ai-env'; import { type AiModelProvider, - type AiProvider, configAiModel, configAiProvider, } from './config'; @@ -188,7 +187,7 @@ async function optionalSecret(message: string): Promise { return trimmed.length > 0 ? trimmed : undefined; } -function getModelChoices(provider: AiProvider): Array<{ +function getModelChoices(provider: AiModelProvider): Array<{ label: string; value: string; }> {