Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .agents/skills/dubstack/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,9 +169,9 @@ dub trunk
## AI Workflow

```bash
dub ai env --gemini-key "<your-key>"
# or
dub ai env --gateway-key "<your-key>"
# choose a provider and enter your key via a masked prompt
# (keys passed as CLI flags would leak into shell history)
dub ai setup

dub config ai-assistant on
dub config ai-defaults create on
Expand Down
8 changes: 4 additions & 4 deletions QUICKSTART.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,16 +56,16 @@ dub ai env \
dub config ai-provider bedrock
```

For direct Anthropic API access:
For direct Anthropic API access (set the key with `dub ai setup` or your shell profile / secrets manager — never pass it as a CLI flag, which leaks it into shell history):

```bash
dub ai env \
--anthropic-key "<your-key>" \
--anthropic-model "claude-sonnet-4-20250514"
dub ai env --anthropic-model "claude-sonnet-4-20250514"

dub config ai-provider anthropic
```

DubStack reads the key from the `DUBSTACK_ANTHROPIC_API_KEY` environment variable.

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.

Expand Down
29 changes: 9 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1014,21 +1014,16 @@ When the wizard writes env vars, DubStack loads them into the current `dub` proc

### `dub ai env`

Write DubStack AI provider settings into your shell profile (macOS/Linux shells).
Write DubStack AI provider model/endpoint settings into your shell profile (macOS/Linux shells).

```bash
# write Gemini key
dub ai env --gemini-key "<your-key>"

# write Gateway key
dub ai env --gateway-key "<your-key>"

# write Anthropic key
dub ai env --anthropic-key "<your-key>"

# write OpenAI key
dub ai env --openai-key "<your-key>"
> **API keys are not set through `dub ai env`.** Passing a secret as a CLI flag leaks it into your shell history and the OS process list. Set keys one of two ways:
>
> - **Interactive:** `dub ai setup` — prompts for the key with masked input that never enters your shell history.
> - **Non-interactive / CI:** export the env var directly, e.g. `export DUBSTACK_OPENAI_API_KEY="<your-key>"` (via your shell profile or a secrets manager).
>
> DubStack reads keys from `DUBSTACK_GEMINI_API_KEY`, `DUBSTACK_ANTHROPIC_API_KEY`, `DUBSTACK_AI_GATEWAY_API_KEY`, and `DUBSTACK_OPENAI_API_KEY`.

```bash
# write Ollama endpoint
dub ai env --ollama-base-url "http://localhost:11434"

Expand All @@ -1053,14 +1048,8 @@ dub ai env \
--bedrock-region "us-west-2" \
--bedrock-model "us.anthropic.claude-sonnet-4-6"

# write multiple keys
dub ai env --gemini-key "<gemini-key>" --gateway-key "<gateway-key>" --openai-key "<openai-key>"

# write key + model together
dub ai env --gemini-key "<gemini-key>" --gemini-model "gemini-3-flash-preview"

# target a specific profile file explicitly
dub ai env --gemini-key "<your-key>" --profile ~/.zshrc
dub ai env --gemini-model "gemini-3-flash-preview" --profile ~/.zshrc
```

Supported automatic profile detection:
Expand Down
8 changes: 3 additions & 5 deletions apps/docs/content/docs/guides/ai-assistant.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -122,23 +122,21 @@ When the wizard writes env vars, DubStack loads them into the current `dub` proc

## `dub ai env`

Use `dub ai env` when you want explicit shell-profile edits without the wizard.
Use `dub ai env` for explicit shell-profile edits to models and endpoints without the wizard.

API keys are **not** set here — passing a secret as a CLI flag leaks it into your shell history and the OS process list. Set keys with `dub ai setup` (masked prompt) or export the env var directly: `DUBSTACK_GEMINI_API_KEY`, `DUBSTACK_ANTHROPIC_API_KEY`, `DUBSTACK_AI_GATEWAY_API_KEY`, or `DUBSTACK_OPENAI_API_KEY`.

```bash
# Gemini
dub ai env --gemini-key "<your-key>"
dub ai env --gemini-model "gemini-3-flash-preview"

# AI Gateway
dub ai env --gateway-key "<your-key>"
dub ai env --gateway-model "google/gemini-3-flash"

# Anthropic
dub ai env --anthropic-key "<your-key>"
dub ai env --anthropic-model "claude-sonnet-4-20250514"

# OpenAI
dub ai env --openai-key "<your-key>"
dub ai env --openai-model "gpt-5.5"

# Bedrock
Expand Down
13 changes: 5 additions & 8 deletions apps/docs/content/docs/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -120,14 +120,11 @@ dub post-merge
## Optional: Enable AI Assistant

```bash
# Add one API key to your shell profile
dub ai env --gemini-key "<your-gemini-key>"
# or:
dub ai env --gateway-key "<your-ai-gateway-key>"
# or:
dub ai env --openai-key "<your-openai-key>"

# Reload your shell
# Choose a provider and enter your key via a masked prompt
# (the wizard never writes the key to your shell history)
dub ai setup

# Reload your shell so the new env vars take effect
source ~/.zshrc

# Enable assistant for this repo
Expand Down
20 changes: 19 additions & 1 deletion packages/cli/src/commands/ai-env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { configureAiEnv } from './ai-env';
import { assertNoApiKeyFlags, configureAiEnv } from './ai-env';

let tempDir: string;
let envSnapshot: NodeJS.ProcessEnv;
Expand Down Expand Up @@ -288,3 +288,21 @@ describe('configureAiEnv', () => {
);
});
});

describe('assertNoApiKeyFlags', () => {
it('throws and names the offending flag when an API key is passed', () => {
expect(() => assertNoApiKeyFlags({ openaiKey: 'sk-proj-leak' })).toThrow(
'--openai-key',
);
});

it('lists every offending flag when several keys are passed', () => {
expect(() =>
assertNoApiKeyFlags({ geminiKey: 'g', anthropicKey: 'a' }),
).toThrow('--gemini-key, --anthropic-key');
});

it('is a no-op when no API key flags are present', () => {
expect(() => assertNoApiKeyFlags({})).not.toThrow();
});
});
38 changes: 37 additions & 1 deletion packages/cli/src/commands/ai-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,41 @@ const OPENAI_MODEL_NAME = 'DUBSTACK_OPENAI_MODEL';
const OLLAMA_BASE_URL_NAME = 'DUBSTACK_OLLAMA_BASE_URL';
const OLLAMA_MODEL_NAME = 'DUBSTACK_OLLAMA_MODEL';

const API_KEY_FLAGS: ReadonlyArray<[keyof ApiKeyFlagInput, string]> = [
['geminiKey', '--gemini-key'],
['anthropicKey', '--anthropic-key'],
['gatewayKey', '--gateway-key'],
['openaiKey', '--openai-key'],
];

export interface ApiKeyFlagInput {
geminiKey?: string;
anthropicKey?: string;
gatewayKey?: string;
openaiKey?: string;
}

/**
* Rejects API keys supplied as CLI flags. Passing a secret in argv leaks it
* into shell history and the OS process list — exposure dub cannot redact.
* Keys must come from the masked `dub ai setup` wizard or a pre-set env var.
*/
export function assertNoApiKeyFlags(flags: ApiKeyFlagInput): void {
const used = API_KEY_FLAGS.filter(([key]) => flags[key] !== undefined).map(
([, name]) => name,
);
if (used.length === 0) return;

throw new DubError(
`Passing API keys as CLI flags is no longer supported (${used.join(', ')}).`,
[
'Run `dub ai setup` for an interactive, masked prompt — the key never enters your shell history or the process list.',
'For non-interactive or CI use, set the env var directly (e.g. `export DUBSTACK_OPENAI_API_KEY=…`) via your shell profile or a secrets manager.',
'If you already ran this with a real key, rotate it now: it was written to your shell history and was visible to other local processes.',
],
);
}

export interface ConfigureAiEnvOptions {
geminiKey?: string;
anthropicKey?: string;
Expand Down Expand Up @@ -61,7 +96,8 @@ export async function configureAiEnv(
!options.ollamaModel
) {
throw new DubError('Provide at least one key, model, or Bedrock setting.', [
"Pass at least one of '--gemini-key', '--anthropic-key', '--gateway-key', '--openai-key', '--gemini-model', '--anthropic-model', '--gateway-model', '--openai-model', '--bedrock-profile', '--bedrock-region', '--bedrock-model', '--ollama-base-url', or '--ollama-model'.",
"Pass at least one of '--gemini-model', '--anthropic-model', '--gateway-model', '--openai-model', '--bedrock-profile', '--bedrock-region', '--bedrock-model', '--ollama-base-url', or '--ollama-model'.",
"To set an API key, run 'dub ai setup' (masked prompt) or export the DUBSTACK_*_API_KEY env var.",
]);
}

Expand Down
22 changes: 12 additions & 10 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { realpathSync } from 'node:fs';
import { createRequire } from 'node:module';
import { pathToFileURL } from 'node:url';
import chalk, { Chalk } from 'chalk';
import { Command } from 'commander';
import { Command, Option } from 'commander';
import { abortCommand } from './commands/abort';
import { absorb } from './commands/absorb';
import { back, listBackHistory } from './commands/back';
Expand Down Expand Up @@ -3333,10 +3333,13 @@ See also:
.description(
'Write DubStack AI provider settings to your shell profile (macOS/Linux)',
)
.option('--gemini-key <key>', 'Set DUBSTACK_GEMINI_API_KEY')
.option('--anthropic-key <key>', 'Set DUBSTACK_ANTHROPIC_API_KEY')
.option('--gateway-key <key>', 'Set DUBSTACK_AI_GATEWAY_API_KEY')
.option('--openai-key <key>', 'Set DUBSTACK_OPENAI_API_KEY')
// Removed: API keys must not be passed in argv (leaks into shell history
// and the process list). Hidden but still parsed so we can reject with a
// helpful message instead of a cryptic "unknown option" error.
.addOption(new Option('--gemini-key <key>').hideHelp())
.addOption(new Option('--anthropic-key <key>').hideHelp())
.addOption(new Option('--gateway-key <key>').hideHelp())
.addOption(new Option('--openai-key <key>').hideHelp())
.option('--ollama-base-url <url>', 'Set DUBSTACK_OLLAMA_BASE_URL')
.option('--gemini-model <model>', 'Set DUBSTACK_GEMINI_MODEL')
.option('--anthropic-model <model>', 'Set DUBSTACK_ANTHROPIC_MODEL')
Expand Down Expand Up @@ -3383,12 +3386,11 @@ See also:
profile?: string;
shell?: string;
}) => {
const { configureAiEnv } = await import('./commands/ai-env');
const { assertNoApiKeyFlags, configureAiEnv } = await import(
'./commands/ai-env'
);
assertNoApiKeyFlags(options);
const result = await configureAiEnv({
geminiKey: options.geminiKey,
anthropicKey: options.anthropicKey,
gatewayKey: options.gatewayKey,
openaiKey: options.openaiKey,
ollamaBaseUrl: options.ollamaBaseUrl,
geminiModel: options.geminiModel,
anthropicModel: options.anthropicModel,
Expand Down
23 changes: 10 additions & 13 deletions packages/cli/src/lib/ai-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,12 +111,9 @@ export function resolveAiProvider(input: {
}

throw new DubError('AI assistant has no configured provider.', [
"Run 'dub ai setup' for an interactive guided setup.",
"Run 'dub ai env --gemini-key <key>' to configure Gemini.",
"Run 'dub ai env --anthropic-key <key>' to configure Anthropic.",
"Run 'dub ai env --gateway-key <key>' to configure the AI Gateway.",
"Run 'dub ai setup' for an interactive guided setup (prompts for your key with masked input).",
'Or set a provider key env var directly: DUBSTACK_GEMINI_API_KEY, DUBSTACK_ANTHROPIC_API_KEY, DUBSTACK_AI_GATEWAY_API_KEY, or DUBSTACK_OPENAI_API_KEY.',
"Run 'dub ai env --bedrock-region <region> --bedrock-model <model>' to configure Bedrock.",
"Run 'dub ai env --openai-key <key>' to configure OpenAI.",
"Run 'dub config ai-provider ollama' to use a local Ollama endpoint.",
]);
}
Expand Down Expand Up @@ -259,8 +256,8 @@ function resolveGoogleProvider(
throw new DubError(
'Gemini is selected but DUBSTACK_GEMINI_API_KEY is not set.',
[
"Run 'dub ai setup' for guided provider setup.",
"Run 'dub ai env --gemini-key <key>' to write the key to your shell profile.",
"Run 'dub ai setup' for guided provider setup (masked key prompt).",
'Or set DUBSTACK_GEMINI_API_KEY in your shell profile or secrets manager.',
],
);
}
Expand Down Expand Up @@ -292,8 +289,8 @@ function resolveAnthropicProvider(
throw new DubError(
'Anthropic is selected but DUBSTACK_ANTHROPIC_API_KEY is not set.',
[
"Run 'dub ai setup' for guided provider setup.",
"Run 'dub ai env --anthropic-key <key>' to write the key to your shell profile.",
"Run 'dub ai setup' for guided provider setup (masked key prompt).",
'Or set DUBSTACK_ANTHROPIC_API_KEY in your shell profile or secrets manager.',
],
);
}
Expand All @@ -319,8 +316,8 @@ function resolveGatewayProvider(
throw new DubError(
'AI Gateway is selected but DUBSTACK_AI_GATEWAY_API_KEY is not set.',
[
"Run 'dub ai setup' for guided provider setup.",
"Run 'dub ai env --gateway-key <key>' to write the key to your shell profile.",
"Run 'dub ai setup' for guided provider setup (masked key prompt).",
'Or set DUBSTACK_AI_GATEWAY_API_KEY in your shell profile or secrets manager.',
],
);
}
Expand Down Expand Up @@ -406,8 +403,8 @@ function resolveOpenAiProvider(
throw new DubError(
'OpenAI is selected but DUBSTACK_OPENAI_API_KEY is not set.',
[
"Run 'dub ai setup' for guided provider setup.",
"Run 'dub ai env --openai-key <key>' to write the key to your shell profile.",
"Run 'dub ai setup' for guided provider setup (masked key prompt).",
'Or set DUBSTACK_OPENAI_API_KEY in your shell profile or secrets manager.',
],
);
}
Expand Down
37 changes: 37 additions & 0 deletions packages/cli/src/lib/history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,24 @@ describe('history', () => {
]);
});

it('redacts --openai-key args in both spaced and = forms', () => {
const sanitized = sanitizeCommandArgs([
'ai',
'env',
'--openai-key',
'sk-proj-abc123_DEF-456ghiJKLmno0pqRstuVwx',
'--openai-key=sk-proj-zzz999_YYY-888xxxWWWvvvUUU',
]);

expect(sanitized).toEqual([
'ai',
'env',
'--openai-key',
'[REDACTED]',
'--openai-key=[REDACTED]',
]);
});

it('does not redact non-secret model args', () => {
const sanitized = sanitizeCommandArgs([
'ai',
Expand Down Expand Up @@ -104,6 +122,25 @@ describe('history', () => {
expect(redacted).toContain('[REDACTED]');
});

it('redacts modern openai/anthropic key formats containing - and _', () => {
const redacted = redactSensitiveText(
[
'dub ai env --openai-key sk-proj-abc123_DEF-456ghiJKLmno0pqRstuVwx',
'pasted sk-ant-api03-abc_def-ghiJKLmno0pqRstuVwx12345 into a note',
].join('\n'),
);

expect(redacted).not.toContain('sk-proj-');
expect(redacted).not.toContain('sk-ant-');
expect(redacted).toContain('[REDACTED]');
});

it('does not redact short sk- branch names', () => {
expect(redactSensitiveText('git checkout sk-fix-login')).toBe(
'git checkout sk-fix-login',
);
});

it('normalizes carriage-return spinner lines to the final visible content', () => {
expect(normalizeHistoryLine('- thinking\r\\ thinking\rfinal output')).toBe(
'final output',
Expand Down
10 changes: 9 additions & 1 deletion packages/cli/src/lib/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const REDACTED_ARGS = new Set([
'--gemini-key',
'--anthropic-key',
'--gateway-key',
'--openai-key',
]);
const REDACTED_PLACEHOLDER = '[REDACTED]';

Expand Down Expand Up @@ -118,6 +119,11 @@ export function sanitizeCommandArgs(args: string[]): string[] {

if (arg.startsWith('--gateway-key=')) {
sanitized[sanitized.length - 1] = `--gateway-key=${REDACTED_PLACEHOLDER}`;
continue;
}

if (arg.startsWith('--openai-key=')) {
sanitized[sanitized.length - 1] = `--openai-key=${REDACTED_PLACEHOLDER}`;
}
}

Expand Down Expand Up @@ -148,7 +154,9 @@ export function redactSensitiveText(value: string): string {
REDACTED_PLACEHOLDER,
);

redacted = redacted.replace(/\bsk-[A-Za-z0-9]{12,}\b/g, REDACTED_PLACEHOLDER);
// Matches modern key formats whose body contains '-'/'_' (e.g. OpenAI
// 'sk-proj-…', Anthropic 'sk-ant-api03-…') as well as legacy 'sk-…' keys.
redacted = redacted.replace(/\bsk-[A-Za-z0-9_-]{12,}/g, REDACTED_PLACEHOLDER);

return redacted;
}
Expand Down
Loading
Loading