diff --git a/config/default.yaml b/config/default.yaml index f6d3595..0697b45 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -226,7 +226,7 @@ models: # pi_embedded: # enabled: false # # timeout_ms: 120000 -# # no_tools_mode: true # keep Pi path text-only in canary; force native for tool-like prompts +# # no_tools_mode: false # when true, force native for tool-like prompts (default: false) # # model: openclaw-default # optional model/session selector passed to Pi runtime # # system_prompt_mode: hybrid # flynn | pi_default | hybrid # # module: "@mariozechner/pi-agent-core" # optional module override diff --git a/docs/plans/2026-02-26-login-auth-mode.md b/docs/plans/2026-02-26-login-auth-mode.md new file mode 100644 index 0000000..138aaed --- /dev/null +++ b/docs/plans/2026-02-26-login-auth-mode.md @@ -0,0 +1,470 @@ +# `/login` Auth Mode Switching Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Extend the `/login mode ` slash command to set `auth_mode` (api_key/oauth/auto) in `config.yaml` for all model tiers using a given provider, with a warning for providers that don't support auth_mode. + +**Architecture:** Add a `mode` field to the `login` command type; extend `parseCommand` to recognise `/login mode `; add a standalone `setProviderAuthMode()` helper in `minimal.ts` that updates all matching tiers via `persistConfig`; thread `configPath` + `config` through `MinimalTuiConfig` so the handler can persist; skip the auth_mode prompt for providers not in `AUTH_MODE_PROVIDERS`. + +**Tech Stack:** TypeScript, Vitest, `yaml` (via `persistConfig`), existing `src/config/persistence.ts`, `src/frontends/tui/commands.ts`, `src/frontends/tui/minimal.ts`, `src/cli/tui.ts`. + +--- + +### Task 1: Extend the `login` command type and parser + +**Files:** +- Modify: `src/frontends/tui/commands.ts:18` (Command union type) +- Modify: `src/frontends/tui/commands.ts:178-182` (parseCommand login branch) +- Modify: `src/frontends/tui/commands.ts:240` (help text) +- Modify: `src/frontends/tui/commands.ts:289` (SLASH_COMMANDS list — no change needed) +- Modify: `src/frontends/tui/commands.ts:321` (COMMAND_TOOLTIPS) +- Test: `src/frontends/tui/commands.test.ts` + +**Step 1: Write the failing tests** + +Add to `commands.test.ts` inside the existing `describe('parseCommand', ...)` block: + +```typescript +it('parses /login with mode subcommand', () => { + expect(parseCommand('/login anthropic mode oauth')).toEqual({ + type: 'login', provider: 'anthropic', mode: 'oauth', + }); + expect(parseCommand('/login openai mode api_key')).toEqual({ + type: 'login', provider: 'openai', mode: 'api_key', + }); + expect(parseCommand('/login anthropic mode auto')).toEqual({ + type: 'login', provider: 'anthropic', mode: 'auto', + }); +}); + +it('parses /login without mode unchanged', () => { + expect(parseCommand('/login')).toEqual({ type: 'login' }); + expect(parseCommand('/login anthropic')).toEqual({ type: 'login', provider: 'anthropic' }); +}); +``` + +**Step 2: Run test to verify it fails** + +```bash +pnpm test:run src/frontends/tui/commands.test.ts +``` + +Expected: FAIL — `mode` property not present on result. + +**Step 3: Update the Command union type** + +In `commands.ts` line 18, change: +```typescript +| { type: 'login'; provider?: string } +``` +to: +```typescript +| { type: 'login'; provider?: string; mode?: 'api_key' | 'oauth' | 'auto' } +``` + +**Step 4: Update parseCommand** + +Replace the existing login block (lines ~177–183): +```typescript +// Login +if (trimmed === '/login') { + return { type: 'login' }; +} +if (trimmed.startsWith('/login ')) { + const provider = trimmed.slice('/login '.length).trim(); + return { type: 'login', provider: provider || undefined }; +} +``` +with: +```typescript +// Login +if (trimmed === '/login') { + return { type: 'login' }; +} +if (trimmed.startsWith('/login ')) { + const rest = trimmed.slice('/login '.length).trim(); + // /login mode + const modeMatch = rest.match(/^(\S+)\s+mode\s+(\S+)$/); + if (modeMatch) { + const modeValue = modeMatch[2].toLowerCase(); + if (modeValue === 'api_key' || modeValue === 'oauth' || modeValue === 'auto') { + return { type: 'login', provider: modeMatch[1] || undefined, mode: modeValue }; + } + } + return { type: 'login', provider: rest || undefined }; +} +``` + +**Step 5: Update help text** + +In `getHelpText()`, update the `/login` line to: +``` + /login [provider] Authenticate (github, openai, anthropic, zai) + /login

mode Set auth mode for provider (api_key|oauth|auto) +``` + +Update `COMMAND_TOOLTIPS['/login']` to: +```typescript +'/login': 'Authenticate with provider; use "mode api_key|oauth|auto" to switch auth mode', +``` + +**Step 6: Run tests to verify they pass** + +```bash +pnpm test:run src/frontends/tui/commands.test.ts +``` + +Expected: all PASS. + +**Step 7: Commit** + +```bash +git add src/frontends/tui/commands.ts src/frontends/tui/commands.test.ts +git commit -m "feat(tui): extend /login parser to accept mode subcommand" +``` + +--- + +### Task 2: Thread `configPath` + `config` through `MinimalTuiConfig` + +**Files:** +- Modify: `src/frontends/tui/minimal.ts:63-81` (MinimalTuiConfig interface) +- Modify: `src/cli/tui.ts:437-458` (MinimalTui constructor call) + +No new tests needed — this is plumbing only. Existing tests cover no regression. + +**Step 1: Add fields to `MinimalTuiConfig`** + +In `minimal.ts`, import `Config` and `persistConfig` at the top. The file already imports from `../../config/index.js` — add `Config` to that import and add a new import for `persistConfig`: + +```typescript +import type { Config, ModelConfig, ModelProvider } from '../../config/index.js'; +import { persistConfig } from '../../config/persistence.js'; +``` + +Then in `MinimalTuiConfig` add: +```typescript +configPath?: string; +currentConfig?: Config; +``` + +**Step 2: Pass `configPath` and `config` when constructing `MinimalTui` in `tui.ts`** + +In `tui.ts` line ~437, add two properties to the constructor object: +```typescript +configPath, +currentConfig: config, +``` + +**Step 3: Typecheck** + +```bash +pnpm typecheck +``` + +Expected: no errors. + +**Step 4: Commit** + +```bash +git add src/frontends/tui/minimal.ts src/cli/tui.ts +git commit -m "feat(tui): thread configPath and currentConfig into MinimalTuiConfig" +``` + +--- + +### Task 3: Implement `setProviderAuthMode` and wire into the login handler + +**Files:** +- Modify: `src/frontends/tui/minimal.ts` (new helper + updated handler) +- Test: `src/frontends/tui/minimal.test.ts` + +**Step 1: Write the failing test** + +`minimal.test.ts` tests the TUI at a higher level. Add a focused unit test for the new helper by extracting it. For now, add to `minimal.test.ts`: + +```typescript +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock persistConfig so we can assert it was called correctly +const { mockPersistConfig } = vi.hoisted(() => ({ mockPersistConfig: vi.fn() })); +vi.mock('../../config/persistence.js', () => ({ persistConfig: mockPersistConfig })); + +describe('applyAuthModeToConfig', () => { + it('sets auth_mode on all tiers whose provider matches', () => { + const config = { + models: { + default: { provider: 'anthropic', model: 'claude-sonnet-4' }, + fast: { provider: 'openai', model: 'gpt-4o-mini' }, + complex: { provider: 'anthropic', model: 'claude-opus-4' }, + }, + } as unknown as import('../../config/schema.js').Config; + + const updated = applyAuthModeToConfig(config, 'anthropic', 'oauth'); + + expect(updated.models.default.auth_mode).toBe('oauth'); + expect(updated.models.complex.auth_mode).toBe('oauth'); + // openai tier must be untouched + expect((updated.models.fast as { auth_mode?: string }).auth_mode).toBeUndefined(); + }); + + it('updates local_providers entries that match', () => { + const config = { + models: { + default: { provider: 'openai', model: 'gpt-4o' }, + local_providers: { + myAnthropic: { provider: 'anthropic', model: 'claude-haiku' }, + }, + }, + } as unknown as import('../../config/schema.js').Config; + + const updated = applyAuthModeToConfig(config, 'anthropic', 'api_key'); + + expect(updated.models.local_providers!['myAnthropic'].auth_mode).toBe('api_key'); + expect((updated.models.default as { auth_mode?: string }).auth_mode).toBeUndefined(); + }); +}); +``` + +Note: `applyAuthModeToConfig` must be exported from `minimal.ts` for test access. + +**Step 2: Run test to verify it fails** + +```bash +pnpm test:run src/frontends/tui/minimal.test.ts +``` + +Expected: FAIL — `applyAuthModeToConfig` is not exported. + +**Step 3: Implement `applyAuthModeToConfig` and `AUTH_MODE_PROVIDERS`** + +Add near the top of `minimal.ts` (after imports): + +```typescript +/** Providers that honour auth_mode at runtime. All others get a warning. */ +export const AUTH_MODE_PROVIDERS: ReadonlySet = new Set(['anthropic', 'openai']); + +/** + * Return a new Config with auth_mode set on every model tier whose provider + * matches targetProvider. Does not mutate the original. + */ +export function applyAuthModeToConfig( + config: Config, + targetProvider: string, + mode: 'api_key' | 'oauth' | 'auto', +): Config { + const applyToTier = (tier: ModelConfig): ModelConfig => + tier.provider === targetProvider ? { ...tier, auth_mode: mode } : tier; + + const updatedModels = { ...config.models }; + + if (updatedModels.default) { + updatedModels.default = applyToTier(updatedModels.default); + } + for (const key of ['fast', 'complex', 'local'] as const) { + const tier = updatedModels[key]; + if (tier) { + updatedModels[key] = applyToTier(tier); + } + } + if (updatedModels.local_providers) { + const updatedLocalProviders: Record = {}; + for (const [name, tier] of Object.entries(updatedModels.local_providers)) { + updatedLocalProviders[name] = applyToTier(tier); + } + updatedModels.local_providers = updatedLocalProviders; + } + + return { ...config, models: updatedModels }; +} +``` + +**Step 4: Run tests to verify they pass** + +```bash +pnpm test:run src/frontends/tui/minimal.test.ts +``` + +Expected: PASS. + +**Step 5: Wire into `handleLoginCommand`** + +Update the switch dispatch in `minimal.ts` line ~537: +```typescript +case 'login': + await this.handleLoginCommand(command.provider, command.mode); + break; +``` + +Update the method signature: +```typescript +private async handleLoginCommand( + provider?: string, + mode?: 'api_key' | 'oauth' | 'auto', +): Promise { +``` + +At the very top of `handleLoginCommand`, before the existing `target` resolution, add the mode-switch fast path: + +```typescript +if (mode !== undefined) { + const resolvedProvider = provider ?? 'anthropic'; + if (!AUTH_MODE_PROVIDERS.has(resolvedProvider)) { + console.log( + `${colors.gray}auth_mode has no effect for ${resolvedProvider}. ` + + `It is only supported for: ${[...AUTH_MODE_PROVIDERS].join(', ')}.${colors.reset}\n`, + ); + return; + } + if (!this.config.currentConfig || !this.config.configPath) { + console.log(`${colors.gray}Config not available — cannot persist auth_mode.${colors.reset}\n`); + return; + } + const updated = applyAuthModeToConfig(this.config.currentConfig, resolvedProvider, mode); + persistConfig(this.config.configPath, updated); + console.log( + `${colors.gray}auth_mode for ${resolvedProvider} set to ${colors.reset}${mode}` + + `${colors.gray}. Restart Flynn for the change to take effect.${colors.reset}\n`, + ); + return; +} +``` + +**Step 6: Add post-credential auth_mode prompt for supported providers** + +In the `anthropic` branch of `handleLoginCommand`, after the credential is stored and before `return`, add (for both the api_key and token paths): + +```typescript +// Offer to set auth_mode if config is available and provider supports it +if (this.config.currentConfig && this.config.configPath) { + const modeInput = (await this.prompt( + `${colors.orange}Set active auth mode?${colors.reset} ${colors.gray}[api_key/oauth/auto/skip] (default: skip):${colors.reset} `, + )).trim().toLowerCase(); + if (modeInput === 'api_key' || modeInput === 'oauth' || modeInput === 'auto') { + const updated = applyAuthModeToConfig(this.config.currentConfig, 'anthropic', modeInput); + persistConfig(this.config.configPath, updated); + console.log(`${colors.gray}auth_mode set to ${modeInput}. Restart Flynn to apply.${colors.reset}\n`); + } +} +``` + +Apply the same block to the `openai` branch (using `'openai'` as the provider string). + +Do **not** add this block to `github` or `zai` branches (they're not in `AUTH_MODE_PROVIDERS`). + +**Step 7: Typecheck** + +```bash +pnpm typecheck +``` + +Expected: no errors. + +**Step 8: Run full test suite** + +```bash +pnpm test:run +``` + +Expected: all pass. + +**Step 9: Commit** + +```bash +git add src/frontends/tui/minimal.ts src/frontends/tui/minimal.test.ts +git commit -m "feat(tui): implement /login mode auth mode switching" +``` + +--- + +### Task 4: Completions for the mode subcommand + +**Files:** +- Modify: `src/frontends/tui/commands.ts` (getCommandCompletions + getCommandTooltip) +- Test: `src/frontends/tui/commands.test.ts` + +**Step 1: Write the failing test** + +Add to `commands.test.ts` inside `describe('getCommandCompletions', ...)`: + +```typescript +it('completes /login mode values', () => { + const completions = getCommandCompletions('/login anthropic mode '); + expect(completions).toContain('/login anthropic mode api_key'); + expect(completions).toContain('/login anthropic mode oauth'); + expect(completions).toContain('/login anthropic mode auto'); +}); + +it('filters mode completions by partial input', () => { + const completions = getCommandCompletions('/login anthropic mode o'); + expect(completions).toEqual(['/login anthropic mode oauth']); +}); +``` + +**Step 2: Run test to verify it fails** + +```bash +pnpm test:run src/frontends/tui/commands.test.ts +``` + +Expected: FAIL. + +**Step 3: Implement completions** + +In `getCommandCompletions`, add before the generic slash-command fallback: + +```typescript +// Complete /login mode +if (trimmed.startsWith('/login ')) { + const rest = trimmed.slice('/login '.length); + const parts = rest.split(/\s+/); + if (parts.length === 3 && parts[1] === 'mode') { + const partial = parts[2].toLowerCase(); + const modes = ['api_key', 'oauth', 'auto']; + return modes + .filter(m => m.startsWith(partial)) + .map(m => `/login ${parts[0]} mode ${m}`); + } + if (parts.length === 2 && parts[1] === 'mod') { + return [`/login ${parts[0]} mode`]; + } +} +``` + +**Step 4: Run tests** + +```bash +pnpm test:run src/frontends/tui/commands.test.ts +``` + +Expected: all PASS. + +**Step 5: Run full suite and typecheck** + +```bash +pnpm test:run && pnpm typecheck +``` + +Expected: all pass, no type errors. + +**Step 6: Commit** + +```bash +git add src/frontends/tui/commands.ts src/frontends/tui/commands.test.ts +git commit -m "feat(tui): add tab completions for /login mode subcommand" +``` + +--- + +### Verification + +```bash +pnpm test:run # full suite passes +pnpm typecheck # no type errors +pnpm lint # no lint errors +``` + +Manual smoke test: +1. `pnpm tui` → type `/login anthropic mode oauth` → confirm config written + restart message +2. `pnpm tui` → type `/login zhipuai mode oauth` → confirm warning printed, no config write +3. `pnpm tui` → type `/login anthropic` → confirm auth_mode prompt appears after credential entry diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 47c8018..5c64659 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -507,7 +507,7 @@ describe('configSchema — backends', () => { expect(result.backends.codex.enabled).toBe(false); expect(result.backends.gemini.enabled).toBe(false); expect(result.backends.pi_embedded.enabled).toBe(false); - expect(result.backends.pi_embedded.no_tools_mode).toBe(true); + expect(result.backends.pi_embedded.no_tools_mode).toBe(false); expect(result.backends.pi_embedded.system_prompt_mode).toBe('hybrid'); expect(result.backends.native.enabled).toBe(true); }); diff --git a/src/config/schema.ts b/src/config/schema.ts index 0b5dd57..e921db2 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -212,7 +212,7 @@ const backendsSchema = z.object({ pi_embedded: z.object({ enabled: z.boolean().default(false), timeout_ms: z.number().min(1_000).max(600_000).default(120_000), - no_tools_mode: z.boolean().default(true), + no_tools_mode: z.boolean().default(false), model: z.string().optional(), system_prompt_mode: z.enum(['flynn', 'pi_default', 'hybrid']).default('hybrid'), module: z.string().optional(),