diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 15e956a6b..847667e1d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -3,6 +3,7 @@ ## [Unreleased] ### Added +- Add `omp config` subcommand for managing settings (`list`, `get`, `set`, `reset`, `path`) - Add `todoCompletion` setting to warn agent when it stops with incomplete todos (up to 3 reminders) ### Changed diff --git a/packages/coding-agent/src/cli/config-cli.ts b/packages/coding-agent/src/cli/config-cli.ts new file mode 100644 index 000000000..29b232b42 --- /dev/null +++ b/packages/coding-agent/src/cli/config-cli.ts @@ -0,0 +1,344 @@ +/** + * Config CLI command handlers. + * + * Handles `omp config ` subcommands for managing settings. + * Uses SETTINGS_DEFS as the source of truth for available settings. + */ + +import chalk from "chalk"; +import { APP_NAME, getAgentDir } from "../config"; +import { SettingsManager } from "../core/settings-manager"; +import { SETTINGS_DEFS, type SettingDef } from "../modes/interactive/components/settings-defs"; +import { theme } from "../modes/interactive/theme/theme"; + +// ============================================================================= +// Types +// ============================================================================= + +export type ConfigAction = "list" | "get" | "set" | "reset" | "path"; + +export interface ConfigCommandArgs { + action: ConfigAction; + key?: string; + value?: string; + flags: { + json?: boolean; + }; +} + +// ============================================================================= +// Setting Filtering +// ============================================================================= + +/** Find setting definition by ID */ +function findSettingDef(id: string): SettingDef | undefined { + return SETTINGS_DEFS.find((def) => def.id === id); +} + +/** Get available values for a setting */ +function getSettingValues(def: SettingDef, sm: SettingsManager): readonly string[] | undefined { + if (def.type === "enum") { + return def.values; + } + if (def.type === "submenu") { + const options = def.getOptions(sm); + if (options.length > 0) { + return options.map((o) => o.value); + } + } + return undefined; +} + +// ============================================================================= +// Argument Parser +// ============================================================================= + +const VALID_ACTIONS: ConfigAction[] = ["list", "get", "set", "reset", "path"]; + +/** + * 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 Parsing +// ============================================================================= + +function parseValue(value: string, def: SettingDef, sm: SettingsManager): unknown { + if (def.type === "boolean") { + const lower = value.toLowerCase(); + if (lower === "true" || lower === "1" || lower === "yes" || lower === "on") { + return true; + } + if (lower === "false" || lower === "0" || lower === "no" || lower === "off") { + return false; + } + throw new Error(`Invalid boolean value: ${value}. Use true/false, yes/no, on/off, or 1/0`); + } + + const validValues = getSettingValues(def, sm); + if (validValues && validValues.length > 0 && !validValues.includes(value)) { + throw new Error(`Invalid value: ${value}. Valid values: ${validValues.join(", ")}`); + } + + return value; +} + +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)); + } + return chalk.yellow(String(value)); +} + +function getTypeDisplay(def: SettingDef, sm: SettingsManager): string { + if (def.type === "boolean") { + return "(boolean)"; + } + const values = getSettingValues(def, sm); + if (values && values.length > 0) { + return `(${values.join("|")})`; + } + return "(string)"; +} + +// ============================================================================= +// Command Handlers +// ============================================================================= + +export async function runConfigCommand(cmd: ConfigCommandArgs): Promise { + const settingsManager = await SettingsManager.create(); + + switch (cmd.action) { + case "list": + handleList(settingsManager, cmd.flags); + break; + case "get": + handleGet(settingsManager, cmd.key, cmd.flags); + break; + case "set": + await handleSet(settingsManager, cmd.key, cmd.value, cmd.flags); + break; + case "reset": + await handleReset(settingsManager, cmd.key, cmd.flags); + break; + case "path": + handlePath(); + break; + } +} + +function handleList(settingsManager: SettingsManager, flags: { json?: boolean }): void { + if (flags.json) { + const result: Record = {}; + for (const def of SETTINGS_DEFS) { + result[def.id] = { + value: def.get(settingsManager), + type: def.type, + description: def.description, + }; + } + console.log(JSON.stringify(result, null, 2)); + return; + } + + console.log(chalk.bold("Settings:\n")); + + // Group by tab + const groups: Record = {}; + for (const def of SETTINGS_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]) { + const value = def.get(settingsManager); + const valueStr = formatValue(value); + const typeStr = getTypeDisplay(def, settingsManager); + console.log(` ${chalk.white(def.id)} = ${valueStr} ${chalk.dim(typeStr)}`); + } + console.log(""); + } +} + +function handleGet(settingsManager: SettingsManager, 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.get(settingsManager); + + if (flags.json) { + console.log(JSON.stringify({ key: def.id, value, type: def.type, description: def.description }, null, 2)); + return; + } + + console.log(formatValue(value)); +} + +async function handleSet( + settingsManager: SettingsManager, + 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); + } + + let parsedValue: unknown; + try { + parsedValue = parseValue(value, def, settingsManager); + } catch (err) { + console.error(chalk.red(String(err))); + process.exit(1); + } + + def.set(settingsManager, parsedValue as never); + + if (flags.json) { + console.log(JSON.stringify({ key: def.id, value: parsedValue })); + } else { + console.log(chalk.green(`${theme.status.success} Set ${def.id} = ${formatValue(parsedValue)}`)); + } +} + +async function handleReset( + settingsManager: SettingsManager, + 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); + } + + // Get default value from a fresh in-memory settings manager + const defaults = SettingsManager.inMemory(); + const defaultValue = def.get(defaults); + + def.set(settingsManager, defaultValue as never); + + if (flags.json) { + console.log(JSON.stringify({ key: def.id, value: defaultValue })); + } else { + console.log(chalk.green(`${theme.status.success} Reset ${def.id} to ${formatValue(defaultValue)}`)); + } +} + +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 Reset a setting to its default value + path Print the config directory path + +${chalk.bold("Options:")} + --json Output as JSON + +${chalk.bold("Examples:")} + ${APP_NAME} config list + ${APP_NAME} config get theme + ${APP_NAME} config set theme catppuccin-mocha + ${APP_NAME} config set autoCompact false + ${APP_NAME} config set thinkingLevel medium + ${APP_NAME} config reset steeringMode + ${APP_NAME} config list --json + +${chalk.bold("Boolean Values:")} + true, false, yes, no, on, off, 1, 0 +`); +} diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index c3dc62930..e2c189ece 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -10,6 +10,7 @@ import { join, resolve } from "node:path"; import { type ImageContent, supportsXhigh } from "@oh-my-pi/pi-ai"; import chalk from "chalk"; import { type Args, parseArgs, printHelp } from "./cli/args"; +import { parseConfigArgs, printConfigHelp, runConfigCommand } from "./cli/config-cli"; import { processFileArguments } from "./cli/file-processor"; import { listModels } from "./cli/list-models"; import { parsePluginArgs, printPluginHelp, runPluginCommand } from "./cli/plugin-cli"; @@ -418,6 +419,17 @@ export async function main(args: string[]) { return; } + // Handle config subcommand + const configCmd = parseConfigArgs(args); + if (configCmd) { + if (args.includes("--help") || args.includes("-h")) { + printConfigHelp(); + return; + } + await runConfigCommand(configCmd); + return; + } + const parsed = parseArgs(args); time("parseArgs"); await maybeAutoChdir(parsed); diff --git a/packages/coding-agent/src/modes/interactive/components/read-tool-group.ts b/packages/coding-agent/src/modes/interactive/components/read-tool-group.ts index 25d5ce371..bdfe449f9 100644 --- a/packages/coding-agent/src/modes/interactive/components/read-tool-group.ts +++ b/packages/coding-agent/src/modes/interactive/components/read-tool-group.ts @@ -85,7 +85,7 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa const statusSymbol = this.formatStatus(entry.status); const pathDisplay = this.formatPath(entry); this.text.setText( - ` ${theme.format.bullet} ${statusSymbol} ${theme.fg("toolTitle", theme.bold("Read"))} ${pathDisplay}`.trimEnd(), + ` ${theme.format.bullet} ${theme.fg("toolTitle", theme.bold("Read"))} ${pathDisplay} ${statusSymbol}`.trimEnd(), ); return; }