/** * Config CLI command handlers. * * Handles `omp config ` subcommands for managing settings. * The settings registry (`config/registry.ts`) is the source of truth for available settings. */ import { APP_NAME, getAgentDir, isRecord } from "@oh-my-pi/pi-utils"; import chalk from "@oh-my-pi/pi-utils/chalk"; import { orderedSettings } from "../config/all-settings"; import { type AnySetting, lookup } from "../config/registry"; import { Settings, settings } from "../config/settings"; import { theme } from "@oh-my-pi/pi-tui/theme"; import { initXdg } from "./commands/init-xdg"; // ============================================================================= // Types // ============================================================================= export type ConfigAction = "list" | "get" | "set" | "reset" | "path" | "init-xdg"; /** Layer `list --scope` reads as written: the agent dir's `config.yml`, or the project's settings files. */ export type ConfigScope = "global" | "project"; export interface ConfigCommandArgs { action: ConfigAction; key?: string; value?: string; flags: { json?: boolean; /** `list` shows only the keys this layer sets, with the values as written. */ scope?: ConfigScope; }; } // ============================================================================= // Setting Filtering // ============================================================================= type CliSettingDef = { setting: AnySetting; path: string; type: string; description: string; tab: string; }; /** Printed instead of a credential value in human output only. */ const REDACTED = "********"; function toSettingDef(setting: AnySetting): CliSettingDef { const ui = setting.ui; return { setting, path: setting.id, type: setting.type, description: ui?.description ?? "", tab: ui?.tab ?? "internal", }; } /** Find setting definition by path */ function findSettingDef(path: string): CliSettingDef | undefined { const setting = lookup(path); return setting ? toSettingDef(setting) : undefined; } // ============================================================================= // Argument Parser // ============================================================================= const VALID_ACTIONS: ConfigAction[] = ["list", "get", "set", "reset", "path", "init-xdg"]; /** * Parse config subcommand arguments. * Returns undefined if not a config command. */ export function parseConfigArgs(args: string[]): ConfigCommandArgs | undefined { if (args.length === 0 || args[0] !== "config") { return undefined; } if (args.length < 2 || args[1] === "--help" || args[1] === "-h") { return { action: "list", flags: {} }; } const action = args[1]; if (!VALID_ACTIONS.includes(action as ConfigAction)) { console.error(chalk.red(`Unknown config command: ${action}`)); console.error(`Valid commands: ${VALID_ACTIONS.join(", ")}`); process.exit(1); } const result: ConfigCommandArgs = { action: action as ConfigAction, flags: {}, }; const positionalArgs: string[] = []; for (let i = 2; i < args.length; i++) { const arg = args[i]; if (arg === "--json") { result.flags.json = true; } else if (!arg.startsWith("-")) { positionalArgs.push(arg); } } if (positionalArgs.length > 0) { result.key = positionalArgs[0]; } if (positionalArgs.length > 1) { result.value = positionalArgs.slice(1).join(" "); } return result; } // ============================================================================= // Value Formatting // ============================================================================= function formatValue(value: unknown): string { if (value === undefined || value === null) { return chalk.dim("(not set)"); } if (typeof value === "boolean") { return value ? chalk.green("true") : chalk.red("false"); } if (typeof value === "number") { return chalk.cyan(String(value)); } if (typeof value === "string") { return chalk.yellow(value); } if (Array.isArray(value) || typeof value === "object") { try { return chalk.yellow(JSON.stringify(value)); } catch { return chalk.yellow(String(value)); } } return chalk.yellow(String(value)); } function getTypeDisplay(def: CliSettingDef): string { const values = def.setting.enumValues; if (values && values.length > 0) { return `(${values.join("|")})`; } switch (def.type) { case "boolean": return "(boolean)"; case "number": return "(number)"; case "array": return "(array)"; case "record": return "(record)"; default: return "(string)"; } } // ============================================================================= // Command Handlers // ============================================================================= export async function runConfigCommand(cmd: ConfigCommandArgs): Promise { if (cmd.flags.scope !== undefined && cmd.action !== "list") { console.error(chalk.red("--scope applies to `config list` only")); process.exit(1); } await Settings.init(); switch (cmd.action) { case "list": if (cmd.flags.scope === undefined) await handleList(cmd.flags); else await handleListLayer(cmd.flags.scope, cmd.flags); break; case "get": handleGet(cmd.key, cmd.flags); break; case "set": await handleSet(cmd.key, cmd.value, cmd.flags); break; case "reset": await handleReset(cmd.key, cmd.flags); break; case "path": handlePath(); break; case "init-xdg": await initXdg(); break; } } async function writeStdout(text: string): Promise { const pending = Promise.withResolvers(); process.stdout.write(text, error => { if (error) { pending.reject(error); return; } pending.resolve(); }); await pending.promise; } async function handleList(flags: { json?: boolean }): Promise { const defs = orderedSettings().map(toSettingDef); if (flags.json) { // A redacted entry omits `value` and says so, rather than substituting a // placeholder string: a consumer cannot tell a stand-in from a real value // and could write it back as the credential. // // Redaction is driven by the value, not by classification alone. Marking an // unset credential as redacted would report every fresh install as having // one configured, which leaks the opposite of what redaction is for. A // configured "" renders as unset, like in the settings panel (credentials // are all strings). const result: Record = {}; for (const def of defs) { const value = def.setting.get(settings); result[def.path] = def.setting.isCredential && value ? { redacted: true, type: def.type, description: def.description } : { value, type: def.type, description: def.description }; } await writeStdout(`${JSON.stringify(result, null, 2)}\n`); return; } console.log(chalk.bold("Settings:\n")); const groups: Record = {}; for (const def of defs) { if (!groups[def.tab]) { groups[def.tab] = []; } groups[def.tab].push(def); } const sortedGroups = Object.keys(groups).sort((a, b) => { if (a === "config") return -1; if (b === "config") return 1; return a.localeCompare(b); }); for (const group of sortedGroups) { console.log(chalk.bold.blue(`[${group}]`)); for (const def of groups[group]) { // `list` dumps every value without anyone asking for a specific // credential, so redact here. `get ` stays an explicit // single-value request and is left alone. An unset or cleared ("") // credential keeps its ordinary rendering: masking it would imply one // is configured. const value = def.setting.get(settings); const valueStr = def.setting.isCredential && value ? REDACTED : formatValue(value); const typeStr = getTypeDisplay(def); console.log(` ${chalk.white(def.path)} = ${valueStr} ${chalk.dim(typeStr)}`); } console.log(""); } } /** * The keys the `scope` layer sets, with the values as written: path-scoped arrays * (`enabledModels`, `enabledProviders`, `disabledProviders`) keep their `{ path, … }` entries * unresolved, so a host can edit the layer's own value without dropping them. Defaults, other * layers and the environment are not consulted. Credentials are redacted as in `list`. */ async function handleListLayer(scope: ConfigScope, flags: { json?: boolean }): Promise { const layer = scope === "project" ? settings.getProjectSettings() : settings.getGlobalSettings(); const entries: [CliSettingDef, unknown][] = []; for (const def of orderedSettings().map(toSettingDef)) { let value: unknown = layer; for (const segment of def.setting.segments) value = isRecord(value) ? value[segment] : undefined; if (value !== undefined) entries.push([def, value]); } if (flags.json) { const result: Record = {}; for (const [def, value] of entries) { result[def.path] = def.setting.isCredential && value ? { redacted: true, type: def.type, description: def.description } : { value, type: def.type, description: def.description }; } await writeStdout(`${JSON.stringify(result, null, 2)}\n`); return; } console.log(chalk.bold(`Settings in the ${scope} layer:\n`)); for (const [def, value] of entries) { const valueStr = def.setting.isCredential && value ? REDACTED : formatValue(value); console.log(` ${chalk.white(def.path)} = ${valueStr} ${chalk.dim(getTypeDisplay(def))}`); } } function handleGet(key: string | undefined, flags: { json?: boolean }): void { if (!key) { console.error(chalk.red(`Usage: ${APP_NAME} config get `)); console.error(chalk.dim(`\nRun '${APP_NAME} config list' to see available keys`)); process.exit(1); } const def = findSettingDef(key); if (!def) { console.error(chalk.red(`Unknown setting: ${key}`)); console.error(chalk.dim(`\nRun '${APP_NAME} config list' to see available keys`)); process.exit(1); } const value = def.setting.get(settings); if (flags.json) { console.log(JSON.stringify({ key: def.path, value, type: def.type, description: def.description }, null, 2)); return; } console.log(formatValue(value)); } async function handleSet(key: string | undefined, value: string | undefined, flags: { json?: boolean }): Promise { if (!key || value === undefined) { console.error(chalk.red(`Usage: ${APP_NAME} config set `)); console.error(chalk.dim(`\nRun '${APP_NAME} config list' to see available keys`)); process.exit(1); } const def = findSettingDef(key); if (!def) { console.error(chalk.red(`Unknown setting: ${key}`)); console.error(chalk.dim(`\nRun '${APP_NAME} config list' to see available keys`)); process.exit(1); } try { def.setting.set(settings, def.setting.parse(value)); await settings.flush(); } catch (err) { console.error(chalk.red(String(err))); process.exit(1); } // Report the value written to config.yml. When another layer or an environment variable still // supplies the effective value, say which instead of echoing its value as if it had been set. const saved = globalValue(def.setting); const shadow = shadowingSource(def.setting); if (flags.json) { console.log(JSON.stringify({ key: def.path, value: saved, ...shadow?.json })); return; } console.log(chalk.green(`${theme.status.success} Set ${def.path} = ${formatValue(saved)}`)); if (shadow) console.log(chalk.yellow(`${theme.status.warning} ${shadow.message}`)); } /** Value `setting` holds in the global config layer — what `config set` wrote. */ function globalValue(setting: AnySetting): unknown { let value: unknown = settings.getGlobalSettings(); for (const segment of setting.segments) value = isRecord(value) ? value[segment] : undefined; return value; } /** Where the effective value comes from when it is not the global config (or the default), if anywhere. */ function shadowingSource(setting: AnySetting): { json: Record; message: string } | undefined { const provenance = setting.provenance(settings); switch (provenance) { case "global": case "default": return undefined; case "env": { const name = setting.envName; if (!name) return undefined; return setting.envFallback ? { json: { fallbackEnv: name }, message: `$${name} is used as a fallback while the saved value is blank.`, } : { json: { overriddenBy: name }, message: `$${name} overrides this value; unset it for the saved value to apply.`, }; } case "project": return { json: { overriddenBy: provenance }, message: "Project settings override this value here; edit or remove it there for the saved value to apply.", }; case "overlay": return { json: { overriddenBy: provenance }, message: "A --config / PI_CONFIG_FILES overlay overrides this value for this process.", }; case "runtime": return { json: { overriddenBy: provenance }, message: "A runtime override supplies the effective value for this process.", }; } } async function handleReset(key: string | undefined, flags: { json?: boolean }): Promise { if (!key) { console.error(chalk.red(`Usage: ${APP_NAME} config reset `)); console.error(chalk.dim(`\nRun '${APP_NAME} config list' to see available keys`)); process.exit(1); } const def = findSettingDef(key); if (!def) { console.error(chalk.red(`Unknown setting: ${key}`)); console.error(chalk.dim(`\nRun '${APP_NAME} config list' to see available keys`)); process.exit(1); } try { // Remove the key rather than writing the default, so later default changes still apply. def.setting.unset(settings); await settings.flush(); } catch (err) { console.error(chalk.red(String(err))); process.exit(1); } // The effective value may now come from another layer or the environment: never echo a credential. const value = def.setting.get(settings); const redacted = def.setting.isCredential && !!value; if (flags.json) { console.log(JSON.stringify(redacted ? { key: def.path, redacted: true } : { key: def.path, value })); } else { console.log( chalk.green(`${theme.status.success} Reset ${def.path} to ${redacted ? REDACTED : formatValue(value)}`), ); } } function handlePath(): void { console.log(getAgentDir()); } // ============================================================================= // Help // ============================================================================= export function printConfigHelp(): void { console.log(`${chalk.bold(`${APP_NAME} config`)} - Manage settings ${chalk.bold("Commands:")} list List all settings with current values get Get a specific setting value set Set a setting value reset Remove a setting from config.yml so its default applies path Print the config directory path init-xdg Initialize XDG Base Directory structure ${chalk.bold("Options:")} --json Output as JSON --scope With list: only the keys the global or project layer sets, as written ${chalk.bold("Examples:")} ${APP_NAME} config list ${APP_NAME} config get theme ${APP_NAME} config set theme catppuccin-mocha ${APP_NAME} config set compaction.enabled false ${APP_NAME} config set defaultThinkingLevel medium ${APP_NAME} config reset steeringMode ${APP_NAME} config list --json ${APP_NAME} config list --scope global --json ${APP_NAME} config init-xdg ${chalk.bold("Boolean Values:")} true, false, yes, no, on, off, 1, 0 `); }