diff --git a/README.md b/README.md index a9c2e2ec..ca591707 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,17 @@ Update existing documentation: openwiki --update ``` +Reconfigure your provider, API key, model, or LangSmith key without running the +agent (for example, to switch to a different key): + +```sh +openwiki --config +``` + +`--config` walks the setup prompts even when credentials already exist. Press +Enter on the API key or LangSmith prompt to keep the current value; only what you +retype is changed. + Show help: ```sh diff --git a/src/cli.tsx b/src/cli.tsx index ae84786e..3be94c47 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -70,6 +70,11 @@ type RunState = errorDiagnostics?: ErrorDiagnostic[]; }; +type ConfigState = + | { status: "editing" } + | { status: "saved"; result: InitSetupResult } + | { status: "error"; message: string }; + type RunLogItem = { actionCount?: number; activeToolCallIds?: string[]; @@ -136,6 +141,9 @@ function App({ command }: AppProps) { const activeRunLog = useRef([]); const [runState, setRunState] = useState({ status: "idle" }); const [completedRuns, setCompletedRuns] = useState([]); + const [configState, setConfigState] = useState({ + status: "editing", + }); const [activeUserMessage, setActiveUserMessage] = useState( command.kind === "run" ? command.userMessage : null, ); @@ -225,13 +233,13 @@ function App({ command }: AppProps) { return; } - if (command.dryRun) { - process.exitCode = 0; - app.exit(); + if (command.kind !== "run") { return; } - if (command.kind !== "run") { + if (command.dryRun) { + process.exitCode = 0; + app.exit(); return; } @@ -389,10 +397,94 @@ function App({ command }: AppProps) { } }, [app, autoExitOnSuccess, runState.status]); + // `--config` is a settings editor: once the setup flow resolves, render the + // outcome one last time and exit without ever invoking the agent. + useEffect(() => { + if (command.kind !== "config") { + return; + } + + if (configState.status === "saved") { + process.exitCode = 0; + app.exit(); + return; + } + + if (configState.status === "error") { + process.exitCode = 1; + app.exit(); + } + }, [app, command.kind, configState.status]); + if (command.kind === "help") { return ; } + if (command.kind === "config") { + if (configState.status === "editing") { + return ( + { + if (result.modelId) { + setSessionModelId(result.modelId); + } + if (result.provider) { + setSessionProvider(result.provider); + } + + setConfigState({ status: "saved", result }); + }} + onError={(message) => { + setConfigState({ status: "error", message }); + }} + /> + ); + } + + if (configState.status === "error") { + return ( + +
+ + + ); + } + + const { result } = configState; + const savedAnything = + result.savedApiKey || + result.savedProvider || + result.savedModelId || + result.savedLangSmithKey; + + return ( + +
+ + {result.provider ? ( + + ) : null} + {result.modelId ? ( + + ) : null} + + + ); + } + if (command.kind === "error") { return ( @@ -3022,7 +3114,10 @@ function Rows({ rows }: RowsProps) { const argv = process.argv.slice(2); const parsedCommand = parseCommand(argv); -if (parsedCommand.kind === "run" && !parsedCommand.dryRun) { +if ( + (parsedCommand.kind === "run" && !parsedCommand.dryRun) || + parsedCommand.kind === "config" +) { await loadOpenWikiEnv(); } @@ -3112,6 +3207,14 @@ function writePrintErrorDiagnostics(error: unknown): void { } function resolveStartupCommand(command: CliCommand): CliCommand { + if (command.kind === "config" && !process.stdin.isTTY) { + return { + kind: "error", + exitCode: 1, + message: "openwiki --config requires an interactive terminal.", + }; + } + if ( command.kind === "run" && !command.dryRun && diff --git a/src/commands.ts b/src/commands.ts index 9840293b..0203bac4 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -19,6 +19,7 @@ export type HelpContent = { export type CliCommand = | { kind: "help"; exitCode: 0 } + | { kind: "config"; exitCode: 0 } | { kind: "run"; exitCode: 0; @@ -43,6 +44,7 @@ export function parseCommand(argv: string[]): CliCommand { let dryRun = false; let modelId: string | null = null; let print = false; + let config = false; let command: OpenWikiCommand = "chat"; const userMessageParts: string[] = []; @@ -71,6 +73,11 @@ export function parseCommand(argv: string[]): CliCommand { continue; } + if (arg === "--config") { + config = true; + continue; + } + if (arg === "--init" || arg === "--update") { const nextCommand = arg === "--init" ? "init" : "update"; @@ -143,6 +150,19 @@ export function parseCommand(argv: string[]): CliCommand { userMessageParts.length > 0 ? userMessageParts.join(" ") : null; const shouldStart = command !== "chat" || userMessage !== null; + if (config) { + if (command !== "chat" || userMessage !== null || print) { + return { + kind: "error", + exitCode: 1, + message: + "--config cannot be combined with --init, --update, --print, or a message.", + }; + } + + return { kind: "config", exitCode: 0 }; + } + if (print && !shouldStart) { return { kind: "error", @@ -178,6 +198,7 @@ export const helpContent: HelpContent = { "openwiki [--modelId ] [message]", "openwiki --init [message]", "openwiki --update [message]", + "openwiki --config", ], commands: [ { @@ -194,6 +215,11 @@ export const helpContent: HelpContent = { label: "--update", description: "Update existing OpenWiki documentation.", }, + { + label: "--config", + description: + "Reconfigure provider, API key, model, and LangSmith without running the agent.", + }, { label: "-p, --print", description: "Run once and print the final assistant output.", @@ -213,6 +239,7 @@ export const helpContent: HelpContent = { "openwiki", "openwiki --init", "openwiki --update", + "openwiki --config", 'openwiki "What can you do?"', 'openwiki -p "Summarize what OpenWiki can do"', "openwiki --modelId gpt-5.5", diff --git a/src/credentials.tsx b/src/credentials.tsx index b0a99e82..fb7ce446 100644 --- a/src/credentials.tsx +++ b/src/credentials.tsx @@ -27,6 +27,10 @@ export type InitSetupResult = { type InitSetupProps = { modelIdOverride?: string | null; + // When true, walk every step even if values already exist, so the user can + // change settings. Empty input on the API key / LangSmith steps means "keep + // the current value" so existing credentials are never nulled out. + reconfigure?: boolean; onComplete: (result: InitSetupResult) => void; onError: (message: string) => void; }; @@ -50,10 +54,17 @@ export function needsCredentialSetup( export function InitSetup({ modelIdOverride = null, + reconfigure = false, onComplete, onError, }: InitSetupProps) { const initialProvider = resolveConfiguredProvider(); + const currentSavedModelId = + modelIdOverride ?? process.env[OPENWIKI_MODEL_ID_ENV_KEY] ?? null; + // Only surface the saved model as a "keep current" option for the provider it + // belongs to; switching providers should fall back to that provider's default. + const injectedModelIdFor = (p: OpenWikiProvider): string | null => + reconfigure && p === initialProvider ? currentSavedModelId : null; const [step, setStep] = useState(null); const [provider, setProvider] = useState(initialProvider); const [apiKey, setApiKey] = useState(null); @@ -66,9 +77,8 @@ export function InitSetup({ const [modelSelectionIndex, setModelSelectionIndex] = useState(() => getModelSelectionIndex( initialProvider, - modelIdOverride ?? - process.env[OPENWIKI_MODEL_ID_ENV_KEY] ?? - getDefaultModelId(initialProvider), + currentSavedModelId ?? getDefaultModelId(initialProvider), + injectedModelIdFor(initialProvider), ), ); const [isCustomModelInput, setIsCustomModelInput] = useState(false); @@ -76,7 +86,11 @@ export function InitSetup({ const [isSaving, setIsSaving] = useState(false); useEffect(() => { - const initialStep = getInitialStep(modelIdOverride, initialProvider); + const initialStep = getInitialStep( + modelIdOverride, + initialProvider, + reconfigure, + ); if (initialStep === null) { onComplete({ @@ -96,14 +110,13 @@ export function InitSetup({ setModelSelectionIndex( getModelSelectionIndex( initialProvider, - modelIdOverride ?? - process.env[OPENWIKI_MODEL_ID_ENV_KEY] ?? - getDefaultModelId(initialProvider), + currentSavedModelId ?? getDefaultModelId(initialProvider), + injectedModelIdFor(initialProvider), ), ); setIsCustomModelInput(false); setStep(initialStep); - }, [initialProvider, modelIdOverride, onComplete]); + }, [initialProvider, modelIdOverride, reconfigure, onComplete]); useInput((inputValue, key) => { if (isSaving || step === null) { @@ -137,7 +150,8 @@ export function InitSetup({ moveSelectionIndex( index, key.upArrow ? -1 : 1, - getModelSelectionOptions(provider).length, + getModelSelectionOptions(provider, injectedModelIdFor(provider)) + .length, ), ); return; @@ -175,12 +189,14 @@ export function InitSetup({ SELECTABLE_OPENWIKI_PROVIDERS[providerSelectionIndex] ?? DEFAULT_PROVIDER; + const injectedModelId = injectedModelIdFor(selectedProvider); setProvider(selectedProvider); setProviderSelectionIndex(getProviderSelectionIndex(selectedProvider)); setModelSelectionIndex( getModelSelectionIndex( selectedProvider, - getDefaultModelId(selectedProvider), + injectedModelId ?? getDefaultModelId(selectedProvider), + injectedModelId, ), ); setIsCustomModelInput(false); @@ -188,6 +204,7 @@ export function InitSetup({ const nextStep = getNextStepAfterProvider( selectedProvider, modelIdOverride, + reconfigure, ); if (nextStep) { @@ -206,15 +223,47 @@ export function InitSetup({ if (step === "api-key") { const trimmedInput = input.trim(); + const hasExistingKey = Boolean( + process.env[getProviderApiKeyEnvKey(provider)], + ); + // In reconfigure mode, an empty entry keeps the current key rather than + // erroring — but only when there is a current key to keep (e.g. the user + // did not just switch to a provider that has no saved key). if (trimmedInput.length === 0) { + if (reconfigure && hasExistingKey) { + setInput(""); + const nextStep = getNextStepAfterApiKey( + provider, + modelIdOverride, + reconfigure, + ); + + if (nextStep) { + setStep(nextStep); + return; + } + + await completeSetup({ + nextApiKey: null, + nextLangSmithKey: langSmithKey, + nextModelId: modelId, + nextProvider: provider, + }); + return; + } + setError(`${getProviderApiKeyEnvKey(provider)} is required.`); return; } setApiKey(trimmedInput); setInput(""); - const nextStep = getNextStepAfterApiKey(provider, modelIdOverride); + const nextStep = getNextStepAfterApiKey( + provider, + modelIdOverride, + reconfigure, + ); if (nextStep) { setStep(nextStep); @@ -236,6 +285,7 @@ export function InitSetup({ modelSelectionIndex, input, isCustomModelInput, + injectedModelIdFor(provider), ); if (!selectedModelId) { @@ -253,7 +303,7 @@ export function InitSetup({ setInput(""); setIsCustomModelInput(false); - if (process.env.LANGSMITH_API_KEY === undefined) { + if (reconfigure || process.env.LANGSMITH_API_KEY === undefined) { setStep("langsmith"); return; } @@ -268,7 +318,12 @@ export function InitSetup({ } if (step === "langsmith") { - const nextLangSmithKey = input.trim(); + const trimmedInput = input.trim(); + // Fresh setup keeps its existing behavior (empty saves an empty value so + // the optional step is not asked again). In reconfigure mode an empty + // entry keeps the current LangSmith key instead of clearing it. + const nextLangSmithKey = + trimmedInput.length > 0 ? trimmedInput : reconfigure ? null : ""; setLangSmithKey(nextLangSmithKey); setInput(""); @@ -415,10 +470,12 @@ export function InitSetup({ {step ? ( ) : ( @@ -426,7 +483,7 @@ export function InitSetup({ )} - {needsCredentialPrompt ? ( + {needsCredentialPrompt || reconfigure ? ( Secrets are masked and saved only after setup. ) : null} @@ -512,21 +569,27 @@ function SetupPanel({ title, children }: SetupPanelProps) { type PromptProps = { input: string; + injectedModelId: string | null; isCustomModelInput: boolean; modelSelectionIndex: number; provider: OpenWikiProvider; providerSelectionIndex: number; + reconfigure: boolean; step: PromptStep; }; function Prompt({ input, + injectedModelId, isCustomModelInput, modelSelectionIndex, provider, providerSelectionIndex, + reconfigure, step, }: PromptProps) { + const canKeepCurrentKey = + reconfigure && Boolean(process.env[getProviderApiKeyEnvKey(provider)]); if (step === "provider") { return ( @@ -554,7 +617,11 @@ function Prompt({ $ {getProviderApiKeyEnvKey(provider)}={" "} {mask(input)} - Press Enter to save it. + + {canKeepCurrentKey + ? "Press Enter to keep the current key, or paste a new one." + : "Press Enter to save it."} + ); } @@ -579,26 +646,28 @@ function Prompt({ Choose {getProviderArticle(provider)} {getProviderLabel(provider)}{" "} model. - {getModelSelectionOptions(provider).map((option, index) => { - if (option.kind === "custom") { + {getModelSelectionOptions(provider, injectedModelId).map( + (option, index) => { + if (option.kind === "custom") { + return ( + + {" "} + Custom model ID + + ); + } + return ( - + {" "} - Custom model ID + {option.label} {option.id} + {option.id === getDefaultModelId(provider) ? ( + default + ) : null} ); - } - - return ( - - {" "} - {option.label} {option.id} - {option.id === getDefaultModelId(provider) ? ( - default - ) : null} - - ); - })} + }, + )} Use up/down arrows, then press Enter. ); @@ -606,10 +675,19 @@ function Prompt({ if (step === "langsmith") { return ( - - $ LANGSMITH_API_KEY optional={" "} - {mask(input)} - + + + $ LANGSMITH_API_KEY optional={" "} + {mask(input)} + + {reconfigure && + process.env.LANGSMITH_API_KEY !== undefined && + process.env.LANGSMITH_API_KEY.length > 0 ? ( + + Press Enter to keep the current key, or paste a new one. + + ) : null} + ); } @@ -625,7 +703,14 @@ function SelectionMarker({ isSelected }: { isSelected: boolean }) { function getInitialStep( modelIdOverride: string | null, provider: OpenWikiProvider, + reconfigure: boolean, ): PromptStep | null { + // Reconfigure always starts from the top and walks every step so the user + // can change any setting; only-missing steps are for first-time setup. + if (reconfigure) { + return "provider"; + } + if (process.env[OPENWIKI_PROVIDER_ENV_KEY] === undefined) { return "provider"; } @@ -651,21 +736,24 @@ function getInitialStep( function getNextStepAfterProvider( provider: OpenWikiProvider, modelIdOverride: string | null, + reconfigure: boolean, ): PromptStep | null { - if (!process.env[getProviderApiKeyEnvKey(provider)]) { + if (reconfigure || !process.env[getProviderApiKeyEnvKey(provider)]) { return "api-key"; } - return getNextStepAfterApiKey(provider, modelIdOverride); + return getNextStepAfterApiKey(provider, modelIdOverride, reconfigure); } function getNextStepAfterApiKey( provider: OpenWikiProvider, modelIdOverride: string | null, + reconfigure: boolean, ): PromptStep | null { if ( - modelIdOverride === null && - process.env[OPENWIKI_MODEL_ID_ENV_KEY] === undefined + reconfigure || + (modelIdOverride === null && + process.env[OPENWIKI_MODEL_ID_ENV_KEY] === undefined) ) { return "model"; } @@ -712,13 +800,29 @@ type ModelSelectionOption = function getModelSelectionOptions( provider: OpenWikiProvider, + currentModelId?: string | null, ): ModelSelectionOption[] { + const presets = getProviderModelOptions(provider).map((model) => ({ + id: model.id, + kind: "preset" as const, + label: model.label, + })); + + // Reconfigure passes the currently-saved model. When it is not one of the + // provider presets (e.g. a custom model set via --modelId or /model), surface + // it as a selectable option so pressing Enter keeps it instead of silently + // switching to the first preset. + const hasCurrent = + currentModelId !== undefined && + currentModelId !== null && + currentModelId.length > 0 && + !presets.some((preset) => preset.id === currentModelId); + return [ - ...getProviderModelOptions(provider).map((model) => ({ - id: model.id, - kind: "preset" as const, - label: model.label, - })), + ...(hasCurrent + ? [{ id: currentModelId, kind: "preset" as const, label: "current" }] + : []), + ...presets, { kind: "custom" }, ]; } @@ -728,9 +832,12 @@ function getSelectedModelId( selectedIndex: number, input: string, isCustomInput: boolean, + currentModelId?: string | null, ): string | "custom" | null { if (!isCustomInput) { - const selectedOption = getModelSelectionOptions(provider)[selectedIndex]; + const selectedOption = getModelSelectionOptions(provider, currentModelId)[ + selectedIndex + ]; if (!selectedOption) { return null; @@ -755,8 +862,12 @@ function getProviderSelectionIndex(provider: OpenWikiProvider): number { function getModelSelectionIndex( provider: OpenWikiProvider, selectedModelId: string, + currentModelId?: string | null, ): number { - const selectedIndex = getModelSelectionOptions(provider).findIndex( + const selectedIndex = getModelSelectionOptions( + provider, + currentModelId, + ).findIndex( (option) => option.kind === "preset" && option.id === selectedModelId, );