diff --git a/eslint.config.mjs b/eslint.config.mjs index 8395814..3059c5d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -113,7 +113,7 @@ export default [ allowNamedFunctions: true } ], - 'func-style': ['error', 'declaration'], + 'func-style': ['error', 'declaration', { allowArrowFunctions: true } ], 'no-multi-spaces': 'error', 'no-multiple-empty-lines': [ 'error', diff --git a/package-lock.json b/package-lock.json index cd5f755..5840f2d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "cli-spinner": "0.2.10", "commander": "12.1.0", "dotenv": "16.4.5", + "fp-ts": "^2.16.9", "fs-extra": "11.2.0", "js-yaml": "4.1.0", "nunjucks": "3.2.4", @@ -4836,6 +4837,12 @@ "node": ">= 12.20" } }, + "node_modules/fp-ts": { + "version": "2.16.9", + "resolved": "https://registry.npmjs.org/fp-ts/-/fp-ts-2.16.9.tgz", + "integrity": "sha512-+I2+FnVB+tVaxcYyQkHUq7ZdKScaBlX53A41mxQtpIccsfyv8PzdzP7fzp2AY832T4aoK6UZ5WRX/ebGd8uZuQ==", + "license": "MIT" + }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", diff --git a/package.json b/package.json index 558bc77..022727d 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,8 @@ "update": "ncu -i", "update-metadata": "ts-node src/app/controllers/update-metadata.ts", "update-views": "ts-node src/app/controllers/update-views.ts", - "validate-yaml": "yamllint '**/*.yml'" + "validate-yaml": "yamllint '**/*.yml'", + "postbuild": "chmod +x ./dist/cli/index.js" }, "keywords": [ "github-actions", @@ -49,6 +50,7 @@ "cli-spinner": "0.2.10", "commander": "12.1.0", "dotenv": "16.4.5", + "fp-ts": "2.16.9", "fs-extra": "11.2.0", "js-yaml": "4.1.0", "nunjucks": "3.2.4", diff --git a/src/app/utils/yaml-operations.ts b/src/app/utils/yaml-operations.ts index 82b59b8..ae11be4 100644 --- a/src/app/utils/yaml-operations.ts +++ b/src/app/utils/yaml-operations.ts @@ -1,6 +1,6 @@ import * as yaml from 'js-yaml'; -import { PromptMetadata, Variable } from '../../shared/types'; +import { PromptMetadata, PromptVariable } from '../../shared/types'; import logger from '../../shared/utils/logger'; import { appConfig } from '../config/app-config'; @@ -73,8 +73,8 @@ export function isValidMetadata(obj: unknown): obj is PromptMetadata { return true; } -function isValidVariable(obj: unknown): obj is Variable { - const variable = obj as Partial; +function isValidVariable(obj: unknown): obj is PromptVariable { + const variable = obj as Partial; return ( typeof variable === 'object' && variable !== null && diff --git a/src/cli/commands/base-command.ts b/src/cli/commands/base-command.ts index aacbad8..33275f8 100644 --- a/src/cli/commands/base-command.ts +++ b/src/cli/commands/base-command.ts @@ -1,135 +1,209 @@ -import os from 'os'; -import path from 'path'; - -import { editor, input, select } from '@inquirer/prompts'; -import chalk from 'chalk'; import { Command } from 'commander'; -import fs from 'fs-extra'; +import * as A from 'fp-ts/lib/Array'; +import * as E from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/function'; +import * as O from 'fp-ts/lib/Option'; +import * as T from 'fp-ts/lib/Task'; +import * as TE from 'fp-ts/lib/TaskEither'; import { ApiResult } from '../../shared/types'; -import { cliConfig } from '../config/cli-config'; -import { ENV_PREFIX, FRAGMENT_PREFIX } from '../constants'; -import { handleApiResult } from '../utils/database'; -import { handleError } from '../utils/errors'; - -export class BaseCommand extends Command { - constructor(name: string, description: string) { - super(name); - this.description(description); - } - action(fn: (...args: any[]) => Promise | void): this { - return super.action(async (...args: any[]) => { - try { - await fn(...args); - } catch (error) { - this.handleError(error, `executing ${this.name()}`); - } - }); - } - - protected async showMenu( - message: string, - choices: Array<{ name: string; value: T }>, - options: { includeGoBack?: boolean; goBackValue?: T; goBackLabel?: string; clearConsole?: boolean } = {} - ): Promise { - const { - includeGoBack = true, - goBackValue = 'back' as T, - goBackLabel = 'Go back', - clearConsole = true - } = options; - - if (clearConsole) { - // console.clear(); - } +// Core types +export type CommandContext = { + readonly args: ReadonlyArray; + readonly options: Readonly>; +}; + +export type CommandError = { + readonly code: string; + readonly message: string; + readonly context?: unknown; +}; + +export interface CommandResult { + readonly completed: boolean; + readonly action?: string; +} - const menuChoices = [...choices]; +export interface BaseCommand { + readonly name: string; + readonly description: string; + readonly execute: (ctx: CommandContext) => Promise>; +} - if (includeGoBack) { - menuChoices.push({ - name: chalk.red(chalk.bold(goBackLabel)), - value: goBackValue - }); +// export type CommandResult = E.Either; + +export type CommandTask = TE.TaskEither; + +// Command creation helper +export const createCommand = ( + name: string, + description: string, + execute: (ctx: CommandContext) => TE.TaskEither +): Command => { + const command = new Command(name); + command.description(description); + + command.action(async (...args) => { + const ctx: CommandContext = { + args: args.slice(0, -1), + options: args[args.length - 1] || {} + }; + await pipe( + execute(ctx), + TE.fold( + (error: CommandError) => + T.of( + (() => { + throw new Error(`Failed to execute ${name} command: ${error.message}`); + })() + ), + (value: A) => T.of(value) + ) + )(); + }); + return command; +}; + +export const createCommandLoop = ( + loopFn: () => TE.TaskEither +): TE.TaskEither => { + const runLoop = (previousResult?: A): TE.TaskEither => { + if (previousResult?.completed) { + return TE.right(previousResult); } - return select({ - message, - choices: menuChoices, - pageSize: cliConfig.MENU_PAGE_SIZE - }); - } - - protected async handleApiResult(result: ApiResult, message: string): Promise { - return handleApiResult(result, message); + return pipe(loopFn(), TE.chain(runLoop)); + }; + return runLoop(); +}; + +// Helper for handling command results +export const handleCommandResult = ( + result: A, + options?: { + onSuccess?: (value: A) => void; + silent?: boolean; } - - protected async getInput(message: string, validate?: (input: string) => boolean | string): Promise { - return input({ - message, - validate: validate || ((value: string): boolean | string => value.trim() !== '' || 'Value cannot be empty') - }); - } - - protected async getMultilineInput(message: string, initialValue: string = ''): Promise { - console.log(chalk.cyan(message)); - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cli-input-')); - const tempFilePath = path.join(tempDir, 'input.txt'); - - try { - const cleanedInitialValue = - initialValue.startsWith(FRAGMENT_PREFIX) || initialValue.startsWith(ENV_PREFIX) ? '' : initialValue; - await fs.writeFile(tempFilePath, cleanedInitialValue); - const input = await editor({ - message: 'Edit your input', - default: cleanedInitialValue, - waitForUseInput: false, - postfix: '.txt' - }); - return input; - } finally { - await fs.remove(tempDir); +): void => { + if (!options?.silent) { + if (options?.onSuccess) { + options.onSuccess(result); } } - - protected async pressKeyToContinue(): Promise { - await input({ message: 'Press Enter to continue...' }); - } - - protected async confirmAction(message: string): Promise { - const action = await this.showMenu<'yes' | 'no'>( - message, - [ - { name: 'Yes', value: 'yes' }, - { name: 'No', value: 'no' } - ], - { includeGoBack: false } - ); - return action === 'yes'; - } - - protected handleError(error: unknown, context: string): void { - handleError(error, context); - } - - async runCommand(program: Command, commandName: string): Promise { - const command = program.commands.find((cmd) => cmd.name() === commandName); - - if (command) { - try { - await command.parseAsync([], { from: 'user' }); - } catch (error) { - console.error(chalk.red(`Error executing command '${commandName}':`, error)); +}; + +// Type guard for command errors +export const isCommandError = (error: unknown): error is CommandError => + typeof error === 'object' && error !== null && 'code' in error && 'message' in error; + +// Helper for creating command errors +export const createCommandError = (code: string, message: string, context?: unknown): CommandError => ({ + code, + message, + context +}); + +// Task composition helpers with fixed types +export const withErrorHandling = (task: T.Task, errorMapper: (error: unknown) => CommandError): CommandTask => + pipe( + task, + TE.fromTask, + TE.mapLeft((error) => errorMapper(error)) + ); + +// Validation chain helper with fixed types +export const validate = ( + value: A, + validators: Array<(value: A) => E.Either> +): E.Either => + validators.reduce( + (result, validator) => pipe(result, E.chain(validator)), + E.right(value) as E.Either + ); + +// Helper for creating TaskEither from Promise +export const taskEitherFromPromise = ( + promise: () => Promise, + errorMapper: (error: unknown) => CommandError +): TE.TaskEither => + TE.tryCatch(async () => { + const result = await promise(); + return result as A; // Allow undefined/void returns + }, errorMapper); + +// Helper for converting ApiResult to TaskEither +export const fromApiResult = (promise: Promise>): TE.TaskEither => + TE.tryCatch( + async () => { + const result = await promise; + + if (!result.success) { + throw new Error(result.error || 'Operation failed'); } - } else { - console.error(chalk.red(`Command '${commandName}' not found.`)); - } - } - - async runSubCommand(command: BaseCommand): Promise { - try { - await command.parseAsync([], { from: 'user' }); - } catch (error) { - this.handleError(error, `running ${command.name()} command`); - } - } -} + return result.data as A; // Allow undefined data + }, + (error) => createCommandError('API_ERROR', String(error)) + ); + +// Helper for converting ApiResult-returning function to TaskEither +export const fromApiFunction = (fn: () => Promise>): TE.TaskEither => + TE.tryCatch( + async () => { + const result = await fn(); + + if (!result.success) { + throw new Error(result.error || 'Operation failed'); + } + return result.data as A; // Allow undefined data + }, + (error) => createCommandError('API_ERROR', String(error)) + ); + +// Helper for handling nullable values +export const fromNullable = ( + value: A | null | undefined, + errorMessage: string +): TE.TaskEither> => + pipe( + O.fromNullable(value), + TE.fromOption(() => createCommandError('NULL_ERROR', errorMessage)) + ); + +// Helper for handling optional values with custom error +export const requireValue = ( + value: A | undefined, + errorCode: string, + errorMessage: string +): TE.TaskEither> => + pipe( + O.fromNullable(value), + TE.fromOption(() => createCommandError(errorCode, errorMessage)) + ); + +// Helper for handling arrays of TaskEither +export const sequenceArray = (tasks: Array>): TE.TaskEither> => + pipe(tasks, A.sequence(TE.ApplicativeSeq)); + +// Helper for mapping arrays with TaskEither +export const traverseArray = ( + arr: Array, + f: (a: A) => TE.TaskEither +): TE.TaskEither> => pipe(arr, A.traverse(TE.ApplicativeSeq)(f)); + +// Helper for conditional execution +export const whenTE = ( + condition: boolean, + task: TE.TaskEither +): TE.TaskEither> => + condition + ? pipe( + task, + TE.map((a) => O.some(a)) + ) + : TE.right(O.none); + +// Helper for handling errors with recovery +export const withErrorRecovery = ( + task: TE.TaskEither, + recovery: (error: CommandError) => TE.TaskEither +): TE.TaskEither => pipe(task, TE.orElse(recovery)); diff --git a/src/cli/commands/config-command.ts b/src/cli/commands/config-command.ts index 174f6f9..296c447 100644 --- a/src/cli/commands/config-command.ts +++ b/src/cli/commands/config-command.ts @@ -1,66 +1,135 @@ import chalk from 'chalk'; +import * as E from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/function'; +import * as TE from 'fp-ts/lib/TaskEither'; -import { BaseCommand } from './base-command'; +import { + CommandError, + CommandResult, + createCommand, + createCommandError, + createCommandLoop, + taskEitherFromPromise +} from './base-command'; +import { createInteractivePrompts, MenuChoice } from './interactive'; import { Config, getConfig, setConfig } from '../../shared/config'; -class ConfigCommand extends BaseCommand { - constructor() { - super('config', 'Manage CLI configuration'); - this.action(this.execute.bind(this)); +// Type definitions +type ConfigAction = 'view' | 'set' | 'back'; + +interface ConfigCommandResult extends CommandResult { + // readonly action: ConfigAction; + readonly key?: keyof Config; + readonly value?: string; +} + +// Create interactive prompts instance +const prompts = createInteractivePrompts(); +// Pure functions for config operations +const viewConfig = (): TE.TaskEither => + pipe( + taskEitherFromPromise( + () => Promise.resolve(getConfig()), + (error) => createCommandError('CONFIG_ERROR', 'Failed to read config', error) + ), + TE.map((config) => { + displayConfig(config); + return { + completed: false, + action: 'view' as const + }; + }) + ); +const setConfigValue = (key: keyof Config, value: string): TE.TaskEither => + pipe( + taskEitherFromPromise( + () => { + setConfig(key, value); + return Promise.resolve(getConfig()); + }, + (error) => createCommandError('CONFIG_ERROR', 'Failed to set config value', error) + ), + TE.map(() => ({ + completed: false, + action: 'set' as const, + key, + value + })) + ); +// Menu choices generators +const createConfigMenuChoices = (): ReadonlyArray> => [ + { name: 'View current configuration', value: 'view' }, + { name: 'Set a configuration value', value: 'set' } +]; +const createConfigKeyChoices = (config: Config): ReadonlyArray> => + (Object.keys(config) as Array).map((key) => ({ + name: key, + value: key + })); +// Helper functions for displaying config values +const formatConfigValue = (key: keyof Config, value: unknown): string => + key === 'ANTHROPIC_API_KEY' ? '********' : String(value); +const displayConfig = (config: Config): void => { + console.log(chalk.cyan('Current configuration:')); + + if (Object.keys(config).length === 0) { + console.log(chalk.yellow('The configuration is empty.')); + return; } - async execute(): Promise { - while (true) { - try { - const action = await this.showMenu<'view' | 'set' | 'back'>('Select an action:', [ - { name: 'View current configuration', value: 'view' }, - { name: 'Set a configuration value', value: 'set' } - ]); + const maxKeyLength = Math.max(...Object.keys(config).map((key) => key.length)); + + Object.entries(config).forEach(([key, value]) => { + const paddingLength = maxKeyLength - key.length; + const paddedSpaces = paddingLength > 0 ? ' '.repeat(paddingLength) : ''; + console.log(`${key}${paddedSpaces} -> ${chalk.green(formatConfigValue(key as keyof Config, value))}`); + }); +}; +// Validation helpers +const validateConfigValue = (key: keyof Config, value: string): E.Either => + value.trim() === '' + ? E.left(createCommandError('INVALID_CONFIG_VALUE', 'Configuration value cannot be empty')) + : E.right(value); +// Interactive workflow functions +const handleSetConfig = (config: Config): TE.TaskEither => + pipe( + prompts.showMenu('Select the configuration key:', createConfigKeyChoices(config)), + TE.chain((key) => { + if (key === 'back') { + return TE.right({ completed: true }); + } + return pipe( + prompts.getInput(`Enter the value for ${chalk.cyan(key)}:`), + TE.chain((value) => + pipe( + validateConfigValue(key, value), + TE.fromEither, + TE.chain(() => setConfigValue(key, value)) + ) + ) + ); + }) + ); +// Main command execution +const executeConfigCommand = (): TE.TaskEither => { + const loop = (): TE.TaskEither => + pipe( + prompts.showMenu('Select an action:', createConfigMenuChoices()), + TE.chain((action) => { switch (action) { case 'view': - this.viewConfig(); - await this.pressKeyToContinue(); - break; + return viewConfig(); case 'set': - await this.setConfigValue(); - break; + return handleSetConfig(getConfig()); case 'back': - return; + return TE.right({ completed: true, action }); + default: + return TE.left(createCommandError('INVALID_ACTION', `Invalid action: ${action}`)); } - } catch (error) { - this.handleError(error, 'config command'); - await this.pressKeyToContinue(); - } - } - } - - private viewConfig(): void { - const currentConfig = getConfig(); - console.log(chalk.cyan('Current configuration:')); - - if (Object.keys(currentConfig).length === 0) { - console.log(chalk.yellow('The configuration is empty.')); - } else { - Object.entries(currentConfig).forEach(([key, value]) => { - console.log(chalk.green(`${key}:`), chalk.yellow(key === 'ANTHROPIC_API_KEY' ? '********' : value)); - }); - } - } - - private async setConfigValue(): Promise { - const currentConfig = getConfig(); - const configKeys = Object.keys(currentConfig) as Array; - const key = await this.showMenu( - 'Select the configuration key:', - configKeys.map((k) => ({ value: k, name: k })) + }) ); + return createCommandLoop(loop); +}; - if (key === 'back') return; - - const value = await this.getInput(`Enter the value for ${chalk.cyan(key)}:`); - setConfig(key, value); - console.log(chalk.green(`Configuration updated: ${key} = ${value}`)); - } -} - -export default new ConfigCommand(); +// Command export +export const configCommand = createCommand('config', 'Manage CLI configuration', executeConfigCommand); diff --git a/src/cli/commands/env-command.ts b/src/cli/commands/env-command.ts index 961c1a4..ff4be2c 100644 --- a/src/cli/commands/env-command.ts +++ b/src/cli/commands/env-command.ts @@ -1,237 +1,268 @@ import chalk from 'chalk'; - -import { BaseCommand } from './base-command'; -import { EnvVar, Fragment } from '../../shared/types'; +import * as A from 'fp-ts/lib/Array'; +import * as Eq from 'fp-ts/lib/Eq'; +import { pipe } from 'fp-ts/lib/function'; +import * as O from 'fp-ts/lib/Option'; +import * as Ord from 'fp-ts/lib/Ord'; +import * as TE from 'fp-ts/lib/TaskEither'; + +import { + CommandError, + createCommand, + fromApiResult, + fromApiFunction, + traverseArray, + CommandResult, + createCommandLoop +} from './base-command'; +import { EnvVariable, PromptFragment, PromptVariable } from '../../shared/types'; import { formatTitleCase, formatSnakeCase } from '../../shared/utils/string-formatter'; import { FRAGMENT_PREFIX } from '../constants'; -import { createEnvVar, readEnvVars, updateEnvVar, deleteEnvVar } from '../utils/env-vars'; +import { createInteractivePrompts, MenuChoice } from './interactive'; +import { createEnvVariable, updateEnvVariable, deleteEnvVariable, readEnvVariables } from '../utils/env-vars'; import { listFragments, viewFragmentContent } from '../utils/fragments'; import { listPrompts, getPromptFiles } from '../utils/prompts'; -class EnvCommand extends BaseCommand { - constructor() { - super('env', 'Manage global environment variables'); - this.action(this.execute.bind(this)); - } - - async execute(): Promise { - while (true) { - try { - const allVariables = await this.getAllUniqueVariables(); - const envVars = await this.handleApiResult(await readEnvVars(), 'Fetched environment variables'); - - if (!envVars) return; - - const action = await this.showMenu<{ name: string; role: string } | 'back'>( - 'Select a variable to manage:', - this.formatVariableChoices(allVariables, envVars) - ); - - if (action === 'back') return; - - await this.manageEnvVar(action); - } catch (error) { - this.handleError(error, 'env command'); - await this.pressKeyToContinue(); - } - } - } - - private formatVariableChoices( - allVariables: Array<{ name: string; role: string }>, - envVars: EnvVar[] - ): Array<{ name: string; value: { name: string; role: string } }> { - const maxNameLength = Math.max(...allVariables.map((v) => formatSnakeCase(v.name).length)); - return allVariables.map((variable) => { - const formattedName = formatSnakeCase(variable.name); - const paddedName = formattedName.padEnd(maxNameLength); - const envVar = envVars.find((v) => formatSnakeCase(v.name) === formattedName); - const status = this.getVariableStatus(envVar); - return { - name: `${chalk.cyan(paddedName)}: ${status}`, - value: variable - }; - }); - } - - private getVariableStatus(envVar: EnvVar | undefined): string { - if (!envVar) return chalk.yellow('Not Set'); - - if (envVar.value.startsWith('Fragment:')) return chalk.blue(envVar.value); - return chalk.green(`Set: ${envVar.value.substring(0, 20)}${envVar.value.length > 20 ? '...' : ''}`); - } - - private async manageEnvVar(variable: { name: string; role: string }): Promise { - const envVars = await this.handleApiResult(await readEnvVars(), 'Fetched environment variables'); - - if (!envVars) return; +// Types +type EnvAction = 'enter' | 'fragment' | 'unset' | 'back'; - const envVar = envVars.find((v) => v.name === variable.name); - const action = await this.showMenu<'enter' | 'fragment' | 'unset' | 'back'>( - `Choose action for ${formatSnakeCase(variable.name)}:`, - [ - { name: 'Enter value', value: 'enter' }, - { name: 'Use fragment', value: 'fragment' }, - { name: 'Unset', value: 'unset' } - ] - ); - switch (action) { - case 'enter': - await this.enterValueForVariable(variable, envVar); - break; - case 'fragment': - await this.assignFragmentToVariable(variable); - break; - case 'unset': - await this.unsetVariable(variable, envVar); - break; - case 'back': - return; - } - - await this.pressKeyToContinue(); - } - - private async enterValueForVariable( - variable: { name: string; role: string }, - envVar: EnvVar | undefined - ): Promise { - try { - const currentValue = envVar?.value || ''; - const value = await this.getMultilineInput(`Value for ${formatSnakeCase(variable.name)}`, currentValue); - - if (envVar) { - const updateResult = await updateEnvVar(envVar.id, value); - - if (updateResult.success) { - console.log(chalk.green(`Updated value for ${formatSnakeCase(variable.name)}`)); - } else { - throw new Error(`Failed to update ${formatSnakeCase(variable.name)}: ${updateResult.error}`); - } - } else { - const createResult = await createEnvVar({ name: variable.name, value, scope: 'global' }); +interface EnvCommandResult extends CommandResult { + readonly action?: EnvAction; +} - if (createResult.success) { - console.log(chalk.green(`Created environment variable ${formatSnakeCase(variable.name)}`)); - } else { - throw new Error(`Failed to create ${formatSnakeCase(variable.name)}: ${createResult.error}`); - } +// Instances +const prompts = createInteractivePrompts(); +// Display formatting +const getVariableStatus = (envVar: EnvVariable | undefined): string => { + if (!envVar) return chalk.yellow('Not Set'); + + const trimmedValue = envVar.value.trim(); + return trimmedValue.startsWith(FRAGMENT_PREFIX) + ? chalk.blue(trimmedValue) + : chalk.green(`Set: ${trimmedValue.substring(0, 20)}${trimmedValue.length > 20 ? '...' : ''}`); +}; +const formatVariableChoices = ( + allVariables: ReadonlyArray, + envVars: ReadonlyArray +): ReadonlyArray> => { + const maxNameLength = Math.max(...allVariables.map((v) => formatSnakeCase(v.name).length)); + return allVariables.map((variable) => { + const formattedName = formatSnakeCase(variable.name); + const coloredName = formattedName; + const paddingLength = maxNameLength - formattedName.length; + const paddedSpaces = paddingLength > 0 ? ' '.repeat(paddingLength) : ''; + const envVar = envVars.find((v) => formatSnakeCase(v.name) === formattedName); + const status = getVariableStatus(envVar); + return { + name: `${coloredName}${paddedSpaces} -> ${status}`, + value: variable + }; + }); +}; +// Variable management +const getAllUniqueVariables = (): TE.TaskEither> => + pipe( + fromApiFunction(() => listPrompts()), + TE.chain((prompts) => + traverseArray(prompts, (prompt) => + prompt.id + ? pipe( + fromApiFunction(() => getPromptFiles(prompt.id!)), + TE.map((details) => details.metadata.variables) + ) + : TE.right([]) + ) + ), + TE.map((variables) => + pipe( + variables.flat(), + A.uniq(Eq.fromEquals((a, b) => a.name === b.name)), + A.sort(Ord.fromCompare((a, b) => a.name.localeCompare(b.name) as -1 | 0 | 1)) + ) + ) + ); +// Variable actions +const enterValueForVariable = ( + variable: PromptVariable, + envVar: O.Option +): TE.TaskEither => + pipe( + prompts.getMultilineInput( + `Value for ${formatSnakeCase(variable.name)}`, + pipe( + envVar, + O.fold( + () => '', + (ev) => ev.value + ) + ) + ), + TE.chain((value) => { + if (value.trim() === '') { + console.log(chalk.yellow('Input canceled. Returning to actions menu.')); + return TE.right(undefined); } - } catch (error) { - this.handleError(error, 'entering value for variable'); - } - } - - private async assignFragmentToVariable(variable: { name: string; role: string }): Promise { - try { - const fragments = await this.handleApiResult(await listFragments(), 'Fetched fragments'); - - if (!fragments) return; - - const selectedFragment = await this.showMenu( - 'Select a fragment: ', - fragments.map((f) => ({ + return pipe( + envVar, + O.fold( + () => + pipe( + fromApiResult( + createEnvVariable({ + name: variable.name, + value, + scope: 'global' + }) + ), + TE.map(() => { + console.log( + chalk.green(`Created environment variable ${formatSnakeCase(variable.name)}`) + ); + }) + ), + (ev) => + pipe( + fromApiResult(updateEnvVariable(ev.id, value)), + TE.map(() => { + console.log(chalk.green(`Updated value for ${formatSnakeCase(variable.name)}`)); + }) + ) + ) + ); + }) + ); +const assignFragmentToVariable = (variable: PromptVariable): TE.TaskEither => + pipe( + fromApiResult(listFragments()), + TE.chain((fragments) => + prompts.showMenu('Select a fragment:', [ + ...fragments.map((f) => ({ name: `${formatTitleCase(f.category)} / ${chalk.blue(f.name)}`, value: f })) - ); - - if (selectedFragment === 'back') { + ]) + ), + TE.chain((fragment) => { + if (fragment === 'back') { console.log(chalk.yellow('Fragment assignment cancelled.')); - return; - } - - const fragmentRef = `${FRAGMENT_PREFIX}${selectedFragment.category}/${selectedFragment.name}`; - const envVars = await this.handleApiResult(await readEnvVars(), 'Fetched environment variables'); - - if (!envVars) return; - - const existingEnvVar = envVars.find((v) => v.name === variable.name); - - if (existingEnvVar) { - const updateResult = await updateEnvVar(existingEnvVar.id, fragmentRef); - - if (!updateResult.success) { - throw new Error(`Failed to update ${formatSnakeCase(variable.name)}: ${updateResult.error}`); - } - } else { - const createResult = await createEnvVar({ - name: variable.name, - value: fragmentRef, - scope: 'global' - }); - - if (!createResult.success) { - throw new Error(`Failed to create ${formatSnakeCase(variable.name)}: ${createResult.error}`); - } + return TE.right(undefined); } - console.log(chalk.green(`Fragment reference assigned to ${formatSnakeCase(variable.name)}`)); - - const fragmentContent = await this.handleApiResult( - await viewFragmentContent(selectedFragment.category, selectedFragment.name), - `Fetched content for fragment ${fragmentRef}` + const fragmentRef = `${FRAGMENT_PREFIX}${fragment.category}/${fragment.name}`; + return pipe( + fromApiResult(readEnvVariables()), + TE.chain((envVars) => { + const existingEnvVar = envVars.find((v) => v.name === variable.name); + return pipe( + O.fromNullable(existingEnvVar), + O.fold( + () => + pipe( + fromApiResult( + createEnvVariable({ + name: variable.name, + value: fragmentRef, + scope: 'global' + }) + ), + TE.map(() => undefined) + ), + (ev) => + pipe( + fromApiResult(updateEnvVariable(ev.id, fragmentRef)), + TE.map(() => undefined) + ) + ) + ); + }), + TE.chain(() => + pipe( + fromApiResult(viewFragmentContent(fragment.category, fragment.name)), + TE.map((content) => { + console.log( + chalk.green(`Fragment reference assigned to ${formatSnakeCase(variable.name)}`) + ); + console.log(chalk.cyan('Fragment content preview:')); + console.log(content.substring(0, 200) + (content.length > 200 ? '...' : '')); + }) + ) + ) ); - - if (fragmentContent) { - console.log(chalk.cyan('Fragment content preview:')); - console.log(fragmentContent.substring(0, 200) + (fragmentContent.length > 200 ? '...' : '')); - } - } catch (error) { - this.handleError(error, 'assigning fragment to variable'); - } - } - - private async unsetVariable(variable: { name: string; role: string }, envVar: EnvVar | undefined): Promise { - try { - if (envVar) { - const deleteResult = await deleteEnvVar(envVar.id); - - if (deleteResult.success) { - console.log(chalk.green(`Unset ${formatSnakeCase(variable.name)}`)); - } else { - throw new Error(`Failed to unset ${formatSnakeCase(variable.name)}: ${deleteResult.error}`); - } - } else { - console.log(chalk.yellow(`${formatSnakeCase(variable.name)} is already empty`)); - } - } catch (error) { - this.handleError(error, 'unsetting variable'); - } - } - - private async getAllUniqueVariables(): Promise> { - try { - const prompts = await this.handleApiResult(await listPrompts(), 'Fetched prompts'); - - if (!prompts) return []; - - const uniqueVariables = new Map(); - - for (const prompt of prompts) { - if (!prompt.id) { - return []; - } - - const details = await this.handleApiResult( - await getPromptFiles(prompt.id), - `Fetched details for prompt ${prompt.id}` - ); - - if (details) { - details.metadata.variables.forEach((v) => { - if (!uniqueVariables.has(v.name)) { - uniqueVariables.set(v.name, { name: v.name, role: v.role }); + }) + ); +const unsetVariable = (variable: PromptVariable, envVar: O.Option): TE.TaskEither => + pipe( + envVar, + O.fold( + () => TE.right(console.log(chalk.yellow(`${formatSnakeCase(variable.name)} is already empty`))), + (ev) => + pipe( + fromApiResult(deleteEnvVariable(ev.id)), + TE.map(() => { + console.log(chalk.green(`Unset ${formatSnakeCase(variable.name)}`)); + }) + ) + ) + ); +// Command execution +const executeEnvCommand = (): TE.TaskEither => { + const loop = (): TE.TaskEither => + pipe( + TE.Do, + TE.bind('variables', () => getAllUniqueVariables()), + TE.bind('envVars', () => fromApiFunction(() => readEnvVariables())), + TE.chain(({ variables, envVars }) => + pipe( + prompts.showMenu( + 'Select a variable to manage:', + formatVariableChoices(variables, envVars) + ), + TE.chain((selectedVariable): TE.TaskEither => { + if (selectedVariable === 'back') { + console.log(chalk.yellow('Returning to main menu.')); + return TE.right({ completed: true }); } - }); - } - } - return Array.from(uniqueVariables.values()).sort((a, b) => a.name.localeCompare(b.name)); - } catch (error) { - this.handleError(error, 'getting all unique variables'); - return []; - } - } -} + return pipe( + prompts.showMenu( + `Choose action for ${formatSnakeCase(selectedVariable.name)}:`, + [ + { name: 'Enter value', value: 'enter' }, + { name: 'Use fragment', value: 'fragment' }, + { name: 'Unset', value: 'unset' } + ] + ), + TE.chain((action) => { + const envVar = O.fromNullable(envVars.find((v) => v.name === selectedVariable.name)); + switch (action) { + case 'enter': + return pipe( + enterValueForVariable(selectedVariable, envVar), + TE.map(() => ({ completed: false })) + ); + case 'fragment': + return pipe( + assignFragmentToVariable(selectedVariable), + TE.map(() => ({ completed: false })) + ); + case 'unset': + return pipe( + unsetVariable(selectedVariable, envVar), + TE.map(() => ({ completed: false })) + ); + case 'back': + console.log(chalk.yellow('Returning to variables menu.')); + return TE.right({ completed: false }); + default: + console.warn(chalk.red(`Unknown action: ${action}`)); + return TE.right({ completed: false }); + } + }) + ); + }) + ) + ) + ); + return createCommandLoop(loop); +}; -export default new EnvCommand(); +export const envCommand = createCommand('env', 'Manage environment variables', executeEnvCommand); diff --git a/src/cli/commands/execute-command.ts b/src/cli/commands/execute-command.ts index 2679658..5ecc2dd 100644 --- a/src/cli/commands/execute-command.ts +++ b/src/cli/commands/execute-command.ts @@ -1,243 +1,243 @@ -import chalk from 'chalk'; -import fs from 'fs-extra'; -import yaml from 'js-yaml'; - -import { BaseCommand } from './base-command'; -import { PromptMetadata, Variable } from '../../shared/types'; -import { processPromptContent, updatePromptWithVariables } from '../../shared/utils/prompt-processing'; -import { getPromptFiles, viewPromptDetails } from '../utils/prompts'; - -class ExecuteCommand extends BaseCommand { - constructor() { - super('execute', 'Execute or inspect a prompt'); - this.option('-p, --prompt ', 'Execute a stored prompt by ID') - .option('-f, --prompt-file ', 'Path to the prompt file (usually prompt.md)') - .option('-m, --metadata-file ', 'Path to the metadata file (usually metadata.yml)') - .option('-i, --inspect', 'Inspect the prompt variables without executing') - .option( - '-fi, --file-input =', - 'Specify a file to use as input for a variable', - this.collect, - {} - ) - .allowUnknownOption(true) - .addHelpText( - 'after', - ` -Dynamic Options: - This command allows setting prompt variables dynamically using additional options. - Variables can be set either by value or by file content. - -Setting variables by value: - Use --variable_name "value" format for each variable. - - Example: - $ execute -f prompt.md -m metadata.yml --source_language english --target_language french - -Setting variables by file content: - Use -fi or --file-input option with variable=filepath format. - - Example: - $ execute -f prompt.md -m metadata.yml -fi communication=input.txt - -Combining value and file inputs: - You can mix both methods in a single command. - - Example: - $ execute -f prompt.md -m metadata.yml --source_language english -fi communication=input.txt --target_language french - -Common Variables: - While variables are prompt-specific, some common ones include: - - --safety_guidelines Set safety rules or ethical considerations - --output_format Set the structure and components of the final output - --extra_guidelines_or_context Set additional information or instructions - -Inspecting Variables: - Use -i or --inspect to see all available variables for a specific prompt: - $ execute -f prompt.md -m metadata.yml -i - -Example Workflow: - 1. Inspect the prompt: - $ execute -f ./prompts/universal_translator/prompt.md -m ./prompts/universal_translator/metadata.yml -i - - 2. Execute the prompt with mixed inputs: - $ execute -f ./prompts/universal_translator/prompt.md -m ./prompts/universal_translator/metadata.yml \\ - --source_language_or_mode english \\ - --target_language_or_mode french \\ - -fi communication=./input.txt - -Note: - - File paths are relative to the current working directory. - - Use quotes for values containing spaces. -` - ) - .action(this.execute.bind(this)); - } - - private collect(value: string, previous: Record): Record { - const [variable, file] = value.split('='); - return { ...previous, [variable]: file }; - } - - private parseDynamicOptions(args: string[]): Record { - const options: Record = {}; - - for (let i = 0; i < args.length; i += 2) { - if (args[i].startsWith('--')) { - const key = args[i].slice(2).replace(/-/g, '_'); - options[key] = args[i + 1]; - } - } - return options; - } - - async execute(options: any, command: any): Promise { - try { - if (options.help) { - this.outputHelp(); - return; - } - - const dynamicOptions = this.parseDynamicOptions(command.args); - - if (options.prompt) { - await this.handleStoredPrompt(options.prompt, dynamicOptions, options.inspect, options.fileInput); - } else if (options.promptFile && options.metadataFile) { - await this.handleFilePrompt( - options.promptFile, - options.metadataFile, - dynamicOptions, - options.inspect, - options.fileInput - ); - } else { - console.error( - chalk.red('Error: You must provide either a prompt ID or both prompt file and metadata file paths.') - ); - this.outputHelp(); - } - } catch (error) { - this.handleError(error, 'execute command'); - } - } - - private async handleStoredPrompt( - promptId: string, - dynamicOptions: Record, - inspect: boolean, - fileInputs: Record - ): Promise { - try { - const promptFiles = await this.handleApiResult(await getPromptFiles(promptId), 'Fetched prompt files'); - - if (!promptFiles) return; - - const { promptContent, metadata } = promptFiles; - - if (inspect) { - await this.inspectPrompt(metadata); - } else { - await this.executePromptWithMetadata(promptContent, metadata, dynamicOptions, fileInputs); - } - } catch (error) { - this.handleError(error, 'handling stored prompt'); - } - } - - private async handleFilePrompt( - promptFile: string, - metadataFile: string, - dynamicOptions: Record, - inspect: boolean, - fileInputs: Record - ): Promise { - try { - const promptContent = await fs.readFile(promptFile, 'utf-8'); - const metadataContent = await fs.readFile(metadataFile, 'utf-8'); - const metadata = yaml.load(metadataContent) as PromptMetadata; - - if (inspect) { - await this.inspectPrompt(metadata); - } else { - await this.executePromptWithMetadata(promptContent, metadata, dynamicOptions, fileInputs); - } - } catch (error) { - this.handleError(error, 'handling file prompt'); - } - } - - private async inspectPrompt(metadata: PromptMetadata): Promise { - try { - await viewPromptDetails( - { - id: '', - title: metadata.title, - primary_category: metadata.primary_category, - description: metadata.description, - tags: metadata.tags, - variables: metadata.variables - } as PromptMetadata & { variables: Variable[] }, - true - ); - } catch (error) { - this.handleError(error, 'inspecting prompt'); - } - } - - private async executePromptWithMetadata( - promptContent: string, - metadata: PromptMetadata, - dynamicOptions: Record, - fileInputs: Record - ): Promise { - try { - const userInputs: Record = {}; - - for (const variable of metadata.variables) { - if (!variable.optional_for_user && !variable.value) { - const snakeCaseName = variable.name.replace(/[{}]/g, '').toLowerCase(); - const hasValue = - (dynamicOptions && snakeCaseName in dynamicOptions) || - (fileInputs && snakeCaseName in fileInputs); - - if (!hasValue) { - throw new Error(`Required variable ${snakeCaseName} is not set`); - } - } - } - - for (const variable of metadata.variables) { - const snakeCaseName = variable.name.replace(/[{}]/g, '').toLowerCase(); - let value = dynamicOptions[snakeCaseName]; - - if (fileInputs[snakeCaseName]) { - try { - value = await fs.readFile(fileInputs[snakeCaseName], 'utf-8'); - console.log(chalk.green(`Loaded file content for ${snakeCaseName}`)); - } catch (error) { - console.error(chalk.red(`Error reading file for ${snakeCaseName}:`, error)); - throw new Error(`Failed to read file for ${snakeCaseName}`); - } - } - - if (value) { - userInputs[variable.name] = value; - } - } - - const updatedPromptContent = updatePromptWithVariables(promptContent, userInputs); - const result = await processPromptContent([{ role: 'user', content: updatedPromptContent }], false, false); - - if (typeof result === 'string') { - console.log(result); - } else { - console.error(chalk.red('Unexpected result format from prompt processing')); - } - } catch (error) { - this.handleError(error, 'executing prompt with metadata'); - } - } -} - -export default new ExecuteCommand(); +// import chalk from 'chalk'; +// import fs from 'fs-extra'; +// import yaml from 'js-yaml'; + +// import { BaseCommand } from './base-command'; +// import { PromptMetadata, Variable } from '../../shared/types'; +// import { processPromptContent, updatePromptWithVariables } from '../../shared/utils/prompt-processing'; +// import { getPromptFiles, viewPromptDetails } from '../utils/prompts'; + +// class ExecuteCommand extends BaseCommand { +// constructor() { +// super('execute', 'Execute or inspect a prompt'); +// this.option('-p, --prompt ', 'Execute a stored prompt by ID') +// .option('-f, --prompt-file ', 'Path to the prompt file (usually prompt.md)') +// .option('-m, --metadata-file ', 'Path to the metadata file (usually metadata.yml)') +// .option('-i, --inspect', 'Inspect the prompt variables without executing') +// .option( +// '-fi, --file-input =', +// 'Specify a file to use as input for a variable', +// this.collect, +// {} +// ) +// .allowUnknownOption(true) +// .addHelpText( +// 'after', +// ` +// Dynamic Options: +// This command allows setting prompt variables dynamically using additional options. +// Variables can be set either by value or by file content. + +// Setting variables by value: +// Use --variable_name "value" format for each variable. + +// Example: +// $ execute -f prompt.md -m metadata.yml --source_language english --target_language french + +// Setting variables by file content: +// Use -fi or --file-input option with variable=filepath format. + +// Example: +// $ execute -f prompt.md -m metadata.yml -fi communication=input.txt + +// Combining value and file inputs: +// You can mix both methods in a single command. + +// Example: +// $ execute -f prompt.md -m metadata.yml --source_language english -fi communication=input.txt --target_language french + +// Common Variables: +// While variables are prompt-specific, some common ones include: + +// --safety_guidelines Set safety rules or ethical considerations +// --output_format Set the structure and components of the final output +// --extra_guidelines_or_context Set additional information or instructions + +// Inspecting Variables: +// Use -i or --inspect to see all available variables for a specific prompt: +// $ execute -f prompt.md -m metadata.yml -i + +// Example Workflow: +// 1. Inspect the prompt: +// $ execute -f ./prompts/universal_translator/prompt.md -m ./prompts/universal_translator/metadata.yml -i + +// 2. Execute the prompt with mixed inputs: +// $ execute -f ./prompts/universal_translator/prompt.md -m ./prompts/universal_translator/metadata.yml \\ +// --source_language_or_mode english \\ +// --target_language_or_mode french \\ +// -fi communication=./input.txt + +// Note: +// - File paths are relative to the current working directory. +// - Use quotes for values containing spaces. +// ` +// ) +// .action(this.execute.bind(this)); +// } + +// private collect(value: string, previous: Record): Record { +// const [variable, file] = value.split('='); +// return { ...previous, [variable]: file }; +// } + +// private parseDynamicOptions(args: string[]): Record { +// const options: Record = {}; + +// for (let i = 0; i < args.length; i += 2) { +// if (args[i].startsWith('--')) { +// const key = args[i].slice(2).replace(/-/g, '_'); +// options[key] = args[i + 1]; +// } +// } +// return options; +// } + +// async execute(options: any, command: any): Promise { +// try { +// if (options.help) { +// this.outputHelp(); +// return; +// } + +// const dynamicOptions = this.parseDynamicOptions(command.args); + +// if (options.prompt) { +// await this.handleStoredPrompt(options.prompt, dynamicOptions, options.inspect, options.fileInput); +// } else if (options.promptFile && options.metadataFile) { +// await this.handleFilePrompt( +// options.promptFile, +// options.metadataFile, +// dynamicOptions, +// options.inspect, +// options.fileInput +// ); +// } else { +// console.error( +// chalk.red('Error: You must provide either a prompt ID or both prompt file and metadata file paths.') +// ); +// this.outputHelp(); +// } +// } catch (error) { +// this.handleError(error, 'execute command'); +// } +// } + +// private async handleStoredPrompt( +// promptId: string, +// dynamicOptions: Record, +// inspect: boolean, +// fileInputs: Record +// ): Promise { +// try { +// const promptFiles = await this.handleApiResult(await getPromptFiles(promptId), 'Fetched prompt files'); + +// if (!promptFiles) return; + +// const { promptContent, metadata } = promptFiles; + +// if (inspect) { +// await this.inspectPrompt(metadata); +// } else { +// await this.executePromptWithMetadata(promptContent, metadata, dynamicOptions, fileInputs); +// } +// } catch (error) { +// this.handleError(error, 'handling stored prompt'); +// } +// } + +// private async handleFilePrompt( +// promptFile: string, +// metadataFile: string, +// dynamicOptions: Record, +// inspect: boolean, +// fileInputs: Record +// ): Promise { +// try { +// const promptContent = await fs.readFile(promptFile, 'utf-8'); +// const metadataContent = await fs.readFile(metadataFile, 'utf-8'); +// const metadata = yaml.load(metadataContent) as PromptMetadata; + +// if (inspect) { +// await this.inspectPrompt(metadata); +// } else { +// await this.executePromptWithMetadata(promptContent, metadata, dynamicOptions, fileInputs); +// } +// } catch (error) { +// this.handleError(error, 'handling file prompt'); +// } +// } + +// private async inspectPrompt(metadata: PromptMetadata): Promise { +// try { +// await viewPromptDetails( +// { +// id: '', +// title: metadata.title, +// primary_category: metadata.primary_category, +// description: metadata.description, +// tags: metadata.tags, +// variables: metadata.variables +// } as PromptMetadata & { variables: Variable[] }, +// true +// ); +// } catch (error) { +// this.handleError(error, 'inspecting prompt'); +// } +// } + +// private async executePromptWithMetadata( +// promptContent: string, +// metadata: PromptMetadata, +// dynamicOptions: Record, +// fileInputs: Record +// ): Promise { +// try { +// const userInputs: Record = {}; + +// for (const variable of metadata.variables) { +// if (!variable.optional_for_user && !variable.value) { +// const snakeCaseName = variable.name.replace(/[{}]/g, '').toLowerCase(); +// const hasValue = +// (dynamicOptions && snakeCaseName in dynamicOptions) || +// (fileInputs && snakeCaseName in fileInputs); + +// if (!hasValue) { +// throw new Error(`Required variable ${snakeCaseName} is not set`); +// } +// } +// } + +// for (const variable of metadata.variables) { +// const snakeCaseName = variable.name.replace(/[{}]/g, '').toLowerCase(); +// let value = dynamicOptions[snakeCaseName]; + +// if (fileInputs[snakeCaseName]) { +// try { +// value = await fs.readFile(fileInputs[snakeCaseName], 'utf-8'); +// console.log(chalk.green(`Loaded file content for ${snakeCaseName}`)); +// } catch (error) { +// console.error(chalk.red(`Error reading file for ${snakeCaseName}:`, error)); +// throw new Error(`Failed to read file for ${snakeCaseName}`); +// } +// } + +// if (value) { +// userInputs[variable.name] = value; +// } +// } + +// const updatedPromptContent = updatePromptWithVariables(promptContent, userInputs); +// const result = await processPromptContent([{ role: 'user', content: updatedPromptContent }], false, false); + +// if (typeof result === 'string') { +// console.log(result); +// } else { +// console.error(chalk.red('Unexpected result format from prompt processing')); +// } +// } catch (error) { +// this.handleError(error, 'executing prompt with metadata'); +// } +// } +// } + +// export default new ExecuteCommand(); diff --git a/src/cli/commands/flush-command.ts b/src/cli/commands/flush-command.ts index 38e67d6..c729ace 100644 --- a/src/cli/commands/flush-command.ts +++ b/src/cli/commands/flush-command.ts @@ -1,41 +1,84 @@ import chalk from 'chalk'; +import { pipe } from 'fp-ts/lib/function'; +import * as TE from 'fp-ts/lib/TaskEither'; -import { BaseCommand } from './base-command'; +import { CommandError, CommandResult, createCommand, createCommandError, taskEitherFromPromise } from './base-command'; +import { confirmAction, pressKeyToContinue } from './interactive'; import { flushData } from '../utils/database'; +import { flushDirectories } from '../utils/file-system'; -class FlushCommand extends BaseCommand { - constructor() { - super('flush', 'Flush and reset all data (preserves config)'); - this.action(this.execute.bind(this)); - } - - async execute(): Promise { - try { - const confirm = await this.confirmAction( - chalk.yellow('Are you sure you want to flush all data? This action cannot be undone.') - ); - - if (confirm) { - await this.performFlush(); - } else { - console.log(chalk.yellow('Flush operation cancelled.')); - } - } catch (error) { - this.handleError(error, 'flush command'); - } finally { - await this.pressKeyToContinue(); - } - } - - private async performFlush(): Promise { - try { - await flushData(); +// Types +interface FlushCommandResult extends CommandResult { + readonly action: 'flush' | 'cancel'; +} + +// Pure functions +const createFlushResult = (action: 'flush' | 'cancel'): FlushCommandResult => ({ + completed: true, + action +}); + +// Effects +const logFlushSuccess = (): TE.TaskEither => + taskEitherFromPromise( + async () => { console.log(chalk.green('Data flushed successfully. The CLI will now exit.')); + }, + (error) => createCommandError('LOGGING_ERROR', 'Failed to log success message', error) + ); + +const logFlushCancelled = (): TE.TaskEither => + taskEitherFromPromise( + async () => { + console.log(chalk.yellow('Flush operation cancelled.')); + }, + (error) => createCommandError('LOGGING_ERROR', 'Failed to log cancellation message', error) + ); + +const exitProcess = (): TE.TaskEither => + taskEitherFromPromise( + async () => { process.exit(0); - } catch (error) { - this.handleError(error, 'flushing data'); - } - } -} + }, + (error) => createCommandError('EXIT_ERROR', 'Failed to exit process', error) + ); + +// Core functions +const performFlush = (): TE.TaskEither => + pipe( + flushDirectories(), + TE.chain(() => + taskEitherFromPromise( + () => flushData(), + (error) => createCommandError('FLUSH_ERROR', 'Failed to flush data', error) + ) + ), + TE.chain(() => logFlushSuccess()), + TE.chain(() => exitProcess()) + ); + +// Main command execution +const executeFlushCommand = (): TE.TaskEither => + pipe( + confirmAction(chalk.yellow('Are you sure you want to flush all data? This action cannot be undone.')), + TE.chain((confirmed) => + confirmed + ? pipe( + performFlush(), + TE.map(() => createFlushResult('flush')) + ) + : pipe( + logFlushCancelled(), + TE.map(() => createFlushResult('cancel')) + ) + ), + TE.chain((result) => + pipe( + pressKeyToContinue(), + TE.map(() => result) + ) + ) + ); -export default new FlushCommand(); +// Export flush command +export const flushCommand = createCommand('flush', 'Flush and reset all data (preserves config)', executeFlushCommand); diff --git a/src/cli/commands/fragments-command.ts b/src/cli/commands/fragments-command.ts index 0f36130..3a8a6ea 100644 --- a/src/cli/commands/fragments-command.ts +++ b/src/cli/commands/fragments-command.ts @@ -1,108 +1,140 @@ import chalk from 'chalk'; - -import { BaseCommand } from './base-command'; -import { Fragment } from '../../shared/types'; +import { pipe } from 'fp-ts/lib/function'; +import * as TE from 'fp-ts/lib/TaskEither'; + +import { + CommandError, + CommandResult, + createCommand, + createCommandError, + createCommandLoop, + fromApiFunction, + fromApiResult +} from './base-command'; +import { PromptFragment } from '../../shared/types'; import { formatTitleCase } from '../../shared/utils/string-formatter'; import { listFragments, viewFragmentContent } from '../utils/fragments'; +import { createInteractivePrompts, MenuChoice } from './interactive'; -type FragmentMenuAction = 'all' | 'category' | 'back'; - -class FragmentsCommand extends BaseCommand { - constructor() { - super('fragments', 'List and view fragments'); - this.action(this.execute.bind(this)); - } +// Types +type FragmentAction = 'all' | 'category' | 'back'; - async execute(): Promise { - while (true) { - try { - const action = await this.showMenu('Select an action:', [ - { name: 'View fragments by category', value: 'category' }, - { name: 'View all fragments', value: 'all' } - ]); +interface FragmentCommandResult extends CommandResult { + readonly action?: FragmentAction; +} +// Create interactive prompts instance +const prompts = createInteractivePrompts(); + +// Pure functions +const createFragmentMenuChoices = (): ReadonlyArray> => [ + { name: 'View fragments by category', value: 'category' }, + { name: 'View all fragments', value: 'all' } +]; + +const sortFragments = (fragments: ReadonlyArray): ReadonlyArray => + [...fragments].sort((a, b) => `${a.category}/${a.name}`.localeCompare(`${b.category}/${b.name}`)); + +const getUniqueCategories = (fragments: ReadonlyArray): ReadonlyArray => + [...new Set(fragments.map((f) => f.category))].sort(); + +const formatFragmentChoice = (fragment: PromptFragment): MenuChoice => ({ + name: `${formatTitleCase(fragment.category)} / ${chalk.green(fragment.name)}`, + value: fragment +}); + +const formatCategoryChoice = (category: string): MenuChoice => ({ + name: formatTitleCase(category), + value: category +}); + +// Effects +const displayFragmentContent = (fragment: PromptFragment, content: string): void => { + console.log(chalk.red(chalk.bold('\nFragment content:'))); + console.log(chalk.cyan('Category:'), formatTitleCase(fragment.category)); + console.log(chalk.cyan('Name:'), fragment.name); + console.log(chalk.cyan('Content:')); + console.log(content); +}; + +// Core functions +const viewFragmentMenu = (fragments: ReadonlyArray): TE.TaskEither => { + const loop = (): TE.TaskEither => + pipe( + prompts.showMenu( + 'Select a fragment to view:', + fragments.map(formatFragmentChoice) + ), + TE.chain((selectedFragment) => { + if (selectedFragment === 'back') return TE.right(undefined); + + return pipe( + fromApiResult(viewFragmentContent(selectedFragment.category, selectedFragment.name)), + TE.map((content) => { + displayFragmentContent(selectedFragment, content); + }), + TE.chain(() => loop()) + ); + }) + ); + return loop(); +}; + +const viewFragmentsByCategory = (fragments: ReadonlyArray): TE.TaskEither => { + const categories = getUniqueCategories(fragments); + + const loop = (): TE.TaskEither => + pipe( + prompts.showMenu('Select a category:', categories.map(formatCategoryChoice)), + TE.chain((category) => { + if (category === 'back') return TE.right(undefined); + + const categoryFragments = fragments.filter((f) => f.category === category); + return pipe( + viewFragmentMenu(categoryFragments), + TE.chain(() => loop()) + ); + }) + ); + return loop(); +}; + +const viewAllFragments = (fragments: ReadonlyArray): TE.TaskEither => + viewFragmentMenu(sortFragments(fragments)); + +// Main command execution +const executeFragmentsCommand = (): TE.TaskEither => { + const loop = (): TE.TaskEither => + pipe( + prompts.showMenu('Select an action:', createFragmentMenuChoices()), + TE.chain((action): TE.TaskEither => { if (action === 'back') { - return; + return TE.right({ completed: true, action }); } - const fragments = await this.handleApiResult(await listFragments(), 'Fetched fragments'); - - if (!fragments) continue; - - if (action === 'all') { - await this.viewAllFragments(fragments); - } else { - await this.viewFragmentsByCategory(fragments); - } - } catch (error) { - this.handleError(error, 'fragments menu'); - await this.pressKeyToContinue(); - } - } - } - - private async viewAllFragments(fragments: Fragment[]): Promise { - const sortedFragments = fragments.sort((a, b) => - `${a.category}/${a.name}`.localeCompare(`${b.category}/${b.name}`) + return pipe( + fromApiFunction(() => listFragments()), + TE.chain((fragments) => { + switch (action) { + case 'all': + return pipe( + viewAllFragments(fragments), + TE.map(() => ({ completed: false, action })) + ); + case 'category': + return pipe( + viewFragmentsByCategory(fragments), + TE.map(() => ({ completed: false, action })) + ); + default: + return TE.left(createCommandError('INVALID_ACTION', `Invalid action: ${action}`)); + } + }) + ); + }) ); - await this.viewFragmentMenu(sortedFragments); - } - - private async viewFragmentsByCategory(fragments: Fragment[]): Promise { - const categories = [...new Set(fragments.map((f) => f.category))].sort(); - while (true) { - const category = await this.showMenu( - 'Select a category:', - categories.map((c) => ({ name: formatTitleCase(c), value: c })) - ); - - if (category === 'back') { - return; - } - - const categoryFragments = fragments.filter((f) => f.category === category); - await this.viewFragmentMenu(categoryFragments); - } - } - - private async viewFragmentMenu(fragments: Fragment[]): Promise { - while (true) { - const selectedFragment = await this.showMenu( - 'Select a fragment to view:', - fragments.map((f) => ({ - name: `${formatTitleCase(f.category)} / ${chalk.green(f.name)}`, - value: f - })) - ); - - if (selectedFragment === 'back') { - return; - } - - await this.displayFragmentContent(selectedFragment); - } - } - - private async displayFragmentContent(fragment: Fragment): Promise { - try { - const content = await this.handleApiResult( - await viewFragmentContent(fragment.category, fragment.name), - `Fetched content for fragment ${fragment.category}/${fragment.name}` - ); - - if (content) { - console.log(chalk.red(chalk.bold('\nFragment content:'))); - console.log(chalk.cyan('Category:'), formatTitleCase(fragment.category)); - console.log(chalk.cyan('Name:'), fragment.name); - console.log(chalk.cyan('Content:')); - console.log(content); - } - } catch (error) { - this.handleError(error, 'viewing fragment content'); - } finally { - await this.pressKeyToContinue(); - } - } -} + return createCommandLoop(loop); +}; -export default new FragmentsCommand(); +// Export fragments command +export const fragmentsCommand = createCommand('fragments', 'List and view fragments', executeFragmentsCommand); diff --git a/src/cli/commands/interactive.ts b/src/cli/commands/interactive.ts new file mode 100644 index 0000000..6dd7f8f --- /dev/null +++ b/src/cli/commands/interactive.ts @@ -0,0 +1,136 @@ +import os from 'os'; +import path from 'path'; + +import { editor, input, select } from '@inquirer/prompts'; +import chalk from 'chalk'; +import { pipe } from 'fp-ts/lib/function'; +import * as TE from 'fp-ts/lib/TaskEither'; +import fs from 'fs-extra'; + +import { CommandError, createCommandError } from '../commands/base-command'; +import { cliConfig } from '../config/cli-config'; +import { ENV_PREFIX, FRAGMENT_PREFIX } from '../constants'; + +export interface MenuChoice { + readonly name: string; + readonly value: T; +} + +export interface MenuOptions { + readonly includeGoBack?: boolean; + readonly goBackValue?: T; + readonly goBackLabel?: string; + readonly clearConsole?: boolean; + readonly pageSize?: number; +} + +export interface InteractivePrompts { + readonly showMenu: ( + message: string, + choices: ReadonlyArray>, + options?: MenuOptions + ) => TE.TaskEither; + + readonly getInput: ( + message: string, + initialValue?: string, + validate?: (input: string) => boolean | string + ) => TE.TaskEither; + + readonly getMultilineInput: (message: string, initialValue?: string) => TE.TaskEither; +} + +export const createInteractivePrompts = (): InteractivePrompts => ({ + showMenu: (message: string, choices: ReadonlyArray>, options: MenuOptions = {}) => { + const { + includeGoBack = true, + goBackValue = 'back' as T, + goBackLabel = 'Go back', + clearConsole = true, + pageSize = cliConfig.MENU_PAGE_SIZE + } = options; + return TE.tryCatch( + async () => { + if (clearConsole) { + // console.clear(); + } + + const menuChoices = [...choices]; + + if (includeGoBack) { + menuChoices.push({ + name: chalk.red(chalk.bold(goBackLabel)), + value: goBackValue + }); + } + return select({ + message, + choices: menuChoices, + pageSize + }); + }, + (error) => createCommandError('PROMPT_ERROR', 'Failed to show menu', error) + ); + }, + + getInput: (message: string, initialValue = '', validate?) => + TE.tryCatch( + () => + input({ + message, + default: initialValue, + validate: validate || ((value: string) => value.trim() !== '' || 'Value cannot be empty') + }), + (error) => createCommandError('PROMPT_ERROR', 'Failed to get input', error) + ), + + getMultilineInput: (message: string, initialValue = '') => + TE.tryCatch( + async () => { + console.log(chalk.cyan(message)); + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cli-input-')); + const tempFilePath = path.join(tempDir, 'input.txt'); + + try { + const cleanedInitialValue = + initialValue.startsWith(FRAGMENT_PREFIX) || initialValue.startsWith(ENV_PREFIX) + ? '' + : initialValue; + await fs.writeFile(tempFilePath, cleanedInitialValue); + return editor({ + message: 'Edit your input', + default: cleanedInitialValue, + waitForUseInput: false, + postfix: '.txt' + }); + } finally { + await fs.remove(tempDir); + } + }, + (error) => createCommandError('PROMPT_ERROR', 'Failed to get multiline input', error) + ) +}); + +// Helper functions for common prompt patterns +export const confirmAction = (message: string): TE.TaskEither => { + const prompts = createInteractivePrompts(); + return pipe( + prompts.showMenu<'yes' | 'no'>( + message, + [ + { name: 'Yes', value: 'yes' }, + { name: 'No', value: 'no' } + ], + { includeGoBack: false } + ), + TE.map((result) => result === 'yes') + ); +}; + +export const pressKeyToContinue = (): TE.TaskEither => { + const prompts = createInteractivePrompts(); + return pipe( + prompts.getInput('Press Enter to continue...'), + TE.map(() => void 0) + ); +}; diff --git a/src/cli/commands/menu-command.ts b/src/cli/commands/menu-command.ts index e6ebc1e..930f5d7 100644 --- a/src/cli/commands/menu-command.ts +++ b/src/cli/commands/menu-command.ts @@ -1,68 +1,154 @@ +// menu-command.ts import chalk from 'chalk'; import { Command } from 'commander'; +import { pipe } from 'fp-ts/lib/function'; +import * as T from 'fp-ts/lib/Task'; +import * as TE from 'fp-ts/lib/TaskEither'; -import { BaseCommand } from './base-command'; +import { + CommandError, + taskEitherFromPromise, + createCommandError, + CommandContext, + createCommand, + CommandResult +} from './base-command'; +import { createInteractivePrompts, MenuChoice } from './interactive'; import { getConfig } from '../../shared/config'; -import { handleError } from '../utils/errors'; import { hasFragments, hasPrompts } from '../utils/file-system'; +// Types type MenuAction = 'sync' | 'prompts' | 'fragments' | 'settings' | 'env' | 'back'; -class MenuCommand extends BaseCommand { - constructor(private program: Command) { - super('menu', 'Main menu for the CLI'); - } +interface MenuCommandResult extends CommandResult { + readonly action?: MenuAction; +} - async execute(): Promise { - while (true) { - try { - const config = getConfig(); - const [promptsExist, fragmentsExist] = await Promise.all([hasPrompts(), hasFragments()]); - const choices: Array<{ name: string; value: MenuAction }> = []; +// Create interactive prompts instance +const prompts = createInteractivePrompts(); +// Pure functions +const createMenuChoices = ( + hasRemoteRepo: boolean, + hasExistingContent: boolean +): ReadonlyArray> => { + const choices: Array> = []; - if (!config.REMOTE_REPOSITORY || (!promptsExist && !fragmentsExist)) { - choices.push({ - name: chalk.green(chalk.bold('Sync with remote repository')), - value: 'sync' - }); - } - - choices.push( - { name: 'Browse and run prompts', value: 'prompts' }, - { name: 'Manage prompt fragments', value: 'fragments' }, - { name: 'Manage environment variables', value: 'env' }, - { name: 'Settings', value: 'settings' } - ); + if (!hasRemoteRepo || !hasExistingContent) { + choices.push({ + name: chalk.green(chalk.bold('Sync with remote repository')), + value: 'sync' + }); + } - // console.clear(); + choices.push( + { name: 'Browse and run prompts', value: 'prompts' }, + { name: 'Manage prompt fragments', value: 'fragments' }, + { name: 'Manage environment variables', value: 'env' }, + { name: 'Settings', value: 'settings' } + ); + return choices; +}; +// Effects +const checkContentStatus = (): TE.TaskEither => + pipe( + taskEitherFromPromise( + () => Promise.all([hasPrompts(), hasFragments()]), + (error) => createCommandError('CONTENT_CHECK_ERROR', 'Failed to check content status', error) + ), + TE.map(([promptsExist, fragmentsExist]) => promptsExist || fragmentsExist) + ); +const executeMenuAction = (program: Command, action: MenuAction): TE.TaskEither => + pipe( + TE.tryCatch( + async () => { + const command = program.commands.find((cmd) => cmd.name() === action); - const action = await this.showMenu( - `${chalk.reset(chalk.italic(chalk.cyan('Want to manage AI prompts with ease ?')))} -${chalk.bold(`${chalk.yellow('Welcome to the Prompt Library !')} -Select an action:`)}`, - choices, - { goBackLabel: 'Exit' } - ); + if (!command) { + throw new Error(`Command '${action}' not found`); + } - if (action === 'back') { - console.log(chalk.yellow('Goodbye!')); - return; + await command.parseAsync([], { from: 'user' }); + }, + (error) => + createCommandError( + 'COMMAND_EXECUTION_ERROR', + `Failed to execute '${action}' command: ${error instanceof Error ? error.message : String(error)}`, + error + ) + ) + ); +const showMenuPrompt = ( + hasRemoteRepo: boolean, + hasExistingContent: boolean +): TE.TaskEither => { + console.clear(); + console.log(chalk.bold(chalk.cyan('Welcome to the Prompt Library !'))); + return prompts.showMenu(`Select an action:`, createMenuChoices(hasRemoteRepo, hasExistingContent), { + goBackLabel: 'Exit' + }); +}; +// Main menu loop +const handleExit = (): TE.TaskEither => + TE.tryCatch( + async () => { + console.log(chalk.yellow('Goodbye!')); + return { + action: 'back' as const, + completed: true + }; + }, + (error) => createCommandError('MENU_ERROR', 'Failed to handle exit', error) + ); +const handleMenuAction = ( + program: Command, + action: Exclude +): TE.TaskEither => + pipe( + executeMenuAction(program, action), + TE.map(() => ({ + action, + completed: false + })) + ); +const runMenuLoop = (program: Command): TE.TaskEither => { + const config = getConfig(); + const hasRemoteRepo = Boolean(config.REMOTE_REPOSITORY); + return pipe( + checkContentStatus(), + TE.chain((hasExistingContent) => showMenuPrompt(hasRemoteRepo, hasExistingContent)), + TE.chain((action) => (action === 'back' ? handleExit() : handleMenuAction(program, action))) + ); +}; +// Command execution +const executeMenuCommand = ( + ctx: CommandContext & { program: Command } +): TE.TaskEither => { + const loop = (): TE.TaskEither => + pipe( + runMenuLoop(ctx.program), + TE.chain((result) => { + if (result.completed) { + return TE.right(result); } + return loop(); + }) + ); + return loop(); +}; - await this.runCommand(this.program, action); - } catch (error) { - this.handleError(error, 'menu command'); - await this.pressKeyToContinue(); - } - } - } -} +// Export menu command +export const menuCommand = (program: Command): Command => + createCommand('menu', 'Main menu for the CLI', (ctx: CommandContext) => executeMenuCommand({ ...ctx, program })); -export async function showMainMenu(program: Command): Promise { - try { - const menuCommand = new MenuCommand(program); - await menuCommand.execute(); - } catch (error) { - handleError(error, 'show main menu'); - } -} +// Export show main menu function +export const showMainMenu = (program: Command): Promise => + pipe( + executeMenuCommand({ args: [], options: {}, program }), + TE.fold( + (error) => { + console.error(chalk.red('Error:'), error.message, error.context || ''); + return T.of(undefined); + }, + () => T.of(undefined) + ) + )(); diff --git a/src/cli/commands/prompts-command.ts b/src/cli/commands/prompts-command.ts index d4ee6f6..3225829 100644 --- a/src/cli/commands/prompts-command.ts +++ b/src/cli/commands/prompts-command.ts @@ -1,489 +1,410 @@ import chalk from 'chalk'; - -import { BaseCommand } from './base-command'; -import { CategoryItem, EnvVar, Fragment, PromptMetadata, Variable } from '../../shared/types'; +import { pipe } from 'fp-ts/lib/function'; +import * as TE from 'fp-ts/lib/TaskEither'; + +import { + CommandError, + CommandResult, + createCommand, + createCommandError, + createCommandLoop, + fromApiFunction, + fromApiResult, + taskEitherFromPromise +} from './base-command'; +import { createInteractivePrompts, MenuChoice } from './interactive'; +import { CategoryItem, EnvVariable, PromptFragment, PromptMetadata, PromptVariable } from '../../shared/types'; import { formatTitleCase, formatSnakeCase } from '../../shared/utils/string-formatter'; import { ENV_PREFIX, FRAGMENT_PREFIX } from '../constants'; import { ConversationManager } from '../utils/conversation-manager'; import { fetchCategories, getPromptDetails, updatePromptVariable } from '../utils/database'; -import { readEnvVars } from '../utils/env-vars'; +import { readEnvVariables } from '../utils/env-vars'; import { listFragments, viewFragmentContent } from '../utils/fragments'; import { viewPromptDetails } from '../utils/prompts'; -type PromptMenuAction = 'all' | 'category' | 'id' | 'back'; -type SelectPromptMenuAction = Variable | 'execute' | 'unset_all' | 'back'; - -class PromptCommand extends BaseCommand { - constructor() { - super('prompts', 'List all prompts and view details'); - this.option('-l, --list', 'List all prompts with their IDs and categories') - .option('-c, --categories', 'List all prompt categories') - .option('-j, --json', 'Output in JSON format (for CI use)') - .action(this.execute.bind(this)); - } - - async execute(options: any): Promise { - try { - const categories = await this.handleApiResult(await fetchCategories(), 'Fetched categories'); - - if (!categories) return; - - if (options.list) { - await this.listAllPromptsForCI(categories, options.json); - return; - } - - if (options.categories) { - await this.listAllCategoriesForCI(categories, options.json); - return; - } - - await this.showPromptMenu(categories); - } catch (error) { - this.handleError(error, 'prompt command'); - } - } - - private async showPromptMenu(categories: Record): Promise { - while (true) { - try { - const action = await this.showMenu('Select an action:', [ - { name: 'View prompts by category', value: 'category' }, - { name: 'View all prompts', value: 'all' }, - { name: 'View all prompts sorted by ID', value: 'id' } - ]); - switch (action) { - case 'all': - await this.listAllPrompts(categories); - break; - case 'category': - await this.listPromptsByCategory(categories); - break; - case 'id': - await this.listPromptsSortedById(categories); - break; - case 'back': - return; - } - } catch (error) { - this.handleError(error, 'prompt menu'); - await this.pressKeyToContinue(); - } - } - } - - private async listAllPromptsForCI(categories: Record, json: boolean): Promise { - const allPrompts = this.getAllPrompts(categories); - - if (json) { - console.log(JSON.stringify(allPrompts, null, 2)); - } else { - console.log(chalk.bold('All prompts:')); - allPrompts.forEach((prompt) => { - console.log(`${chalk.green(prompt.id)} - ${chalk.cyan(prompt.category)} / ${prompt.title}`); - }); - } - } - - private async listAllCategoriesForCI(categories: Record, json: boolean): Promise { - const categoryList = Object.keys(categories).sort(); - - if (json) { - console.log(JSON.stringify(categoryList, null, 2)); - } else { - console.log(chalk.bold('All categories:')); - categoryList.forEach((category) => { - console.log(chalk.cyan(category)); - }); - } - } +type PromptAction = 'all' | 'category' | 'id' | 'back'; +type VariableAction = 'enter' | 'fragment' | 'env' | 'unset' | 'back'; +type PromptExecuteAction = 'continue' | 'back'; - private getAllPrompts(categories: Record): Array { - return Object.entries(categories) - .flatMap(([category, prompts]) => - prompts.map((prompt) => ({ - ...prompt, - category - })) - ) - .sort((a, b) => a.title.localeCompare(b.title)); - } +interface PromptCommandResult extends CommandResult { + readonly action?: PromptAction; +} - private async listAllPrompts(categories: Record): Promise { - const allPrompts = this.getAllPrompts(categories); - await this.selectAndManagePrompt(allPrompts); +const prompts = createInteractivePrompts(); +const createPromptMenuChoices = (): ReadonlyArray> => [ + { name: 'View prompts by category', value: 'category' }, + { name: 'View all prompts', value: 'all' }, + { name: 'View all prompts sorted by ID', value: 'id' } +]; +const createVariableActionChoices = (hasEnvVar: boolean): ReadonlyArray> => [ + { name: 'Enter value', value: 'enter' }, + { name: 'Use fragment', value: 'fragment' }, + { + name: hasEnvVar ? chalk.green(chalk.bold('Use environment variable')) : 'Use environment variable', + value: 'env' + }, + { name: 'Unset', value: 'unset' } +]; +const getVariableNameColor = (variable: PromptVariable): ((text: string) => string) => { + if (variable.value) { + if (variable.value.startsWith(FRAGMENT_PREFIX)) return chalk.blue; + + if (variable.value.startsWith(ENV_PREFIX)) return chalk.magenta; + return chalk.green; } - - private async listPromptsByCategory(categories: Record): Promise { - while (true) { - const sortedCategories = Object.keys(categories).sort((a, b) => a.localeCompare(b)); - const category = await this.showMenu( - 'Select a category:', - sortedCategories.map((category) => ({ - name: formatTitleCase(category), - value: category - })) - ); - - if (category === 'back') return; - - const promptsWithCategory = categories[category].map((prompt) => ({ + return variable.optional_for_user ? chalk.yellow : chalk.red; +}; +const getVariableHint = (variable: PromptVariable, envVars: ReadonlyArray): string => + !variable.value && envVars.some((env) => env.name === variable.name) ? chalk.magenta(' (env available)') : ''; +const formatVariableName = (variable: PromptVariable, envVars: ReadonlyArray): string => { + const snakeCaseName = formatSnakeCase(variable.name); + const nameColor = getVariableNameColor(variable); + const hint = getVariableHint(variable, envVars); + return `${chalk.reset('Assign')} ${nameColor(snakeCaseName)}${chalk.reset(variable.optional_for_user ? '' : '*')}${hint}`; +}; +const getAllPrompts = (categories: Record): Array => + Object.entries(categories) + .flatMap(([category, prompts]) => + prompts.map((prompt) => ({ ...prompt, category - })); - await this.selectAndManagePrompt(promptsWithCategory); - } - } - - private async listPromptsSortedById(categories: Record): Promise { - const allPrompts = this.getAllPrompts(categories).sort((a, b) => Number(a.id) - Number(b.id)); - await this.selectAndManagePrompt(allPrompts); - } - - private async selectAndManagePrompt(prompts: (CategoryItem & { category: string })[]): Promise { - while (true) { - const selectedPrompt = await this.showMenu( - 'Select a prompt or action:', - prompts.map((p) => ({ - name: `${formatTitleCase(p.category)} / ${chalk.green(p.title)} (ID: ${p.id})`, - value: p + })) + ) + .sort((a, b) => a.title.localeCompare(b.title)); +const assignValueToVariable = (promptId: string, variable: PromptVariable): TE.TaskEither => + pipe( + prompts.getMultilineInput(`Value for ${formatSnakeCase(variable.name)}:`, variable.value || ''), + TE.chain((value) => + pipe( + fromApiResult(updatePromptVariable(promptId, variable.name, value)), + TE.map(() => { + console.log(chalk.green(`Value set for ${formatSnakeCase(variable.name)}`)); + }) + ) + ) + ); +const assignFragmentToVariable = (promptId: string, variable: PromptVariable): TE.TaskEither => + pipe( + fromApiResult(listFragments()), + TE.chain((fragments) => + prompts.showMenu( + 'Select a fragment:', + fragments.map((f) => ({ + name: `${formatTitleCase(f.category)} / ${chalk.blue(f.name)}`, + value: f })) - ); - - if (selectedPrompt === 'back') return; - - await this.managePrompt(selectedPrompt); - } - } - - private async managePrompt(prompt: CategoryItem): Promise { - while (true) { - try { - const details = await this.handleApiResult(await getPromptDetails(prompt.id), 'Fetched prompt details'); - - if (!details) return; - - await viewPromptDetails(details); - - const action = await this.selectPromptAction(details); - - if (action === 'back') return; - - if (action === 'execute') { - await this.executePromptWithAssignment(prompt.id); - } else if (action === 'unset_all') { - await this.unsetAllVariables(prompt.id); - } else { - await this.assignVariable(prompt.id, action); - } - } catch (error) { - this.handleError(error, 'managing prompt'); - await this.pressKeyToContinue(); - } - } - } - - private async selectPromptAction(details: PromptMetadata): Promise { - const choices: Array<{ name: string; value: SelectPromptMenuAction }> = []; - const allRequiredSet = details.variables.every((v) => v.optional_for_user || v.value); - - if (allRequiredSet) { - choices.push({ name: chalk.green(chalk.bold('Execute prompt')), value: 'execute' }); - } - - const envVarsResult = await readEnvVars(); - const envVars = envVarsResult.success ? envVarsResult.data || [] : []; - choices.push(...this.formatVariableChoices(details.variables, envVars)); - - choices.push({ name: chalk.red('Unset all variables'), value: 'unset_all' }); - return this.showMenu( - `Select an action for prompt "${chalk.cyan(details.title)}":`, - choices, - { clearConsole: false } - ); - } - - private formatVariableChoices(variables: Variable[], envVars: EnvVar[]): Array<{ name: string; value: Variable }> { - return variables.map((v) => { - const snakeCaseName = formatSnakeCase(v.name); - const nameColor = this.getVariableNameColor(v); - const hint = this.getVariableHint(v, envVars); - return { - name: `${chalk.reset('Assign')} ${nameColor(snakeCaseName)}${chalk.reset(v.optional_for_user ? '' : '*')}${hint}`, - value: v - }; - }); - } - - private getVariableNameColor(v: Variable): (text: string) => string { - if (v.value) { - if (v.value.startsWith(FRAGMENT_PREFIX)) return chalk.blue; - - if (v.value.startsWith(ENV_PREFIX)) return chalk.magenta; - return chalk.green; - } - return v.optional_for_user ? chalk.yellow : chalk.red; - } - - private getVariableHint(v: Variable, envVars: EnvVar[]): string { - if (!v.value) { - const matchingEnvVar = envVars.find((env) => env.name === v.name); - - if (matchingEnvVar) { - return chalk.magenta(' (env available)'); + ) + ), + TE.chain((fragment) => { + if (fragment === 'back') { + console.log(chalk.yellow('Fragment assignment cancelled.')); + return TE.right(undefined); } - } - return ''; - } - - private async assignVariable(promptId: string, variable: Variable): Promise { - const envVarsResult = await readEnvVars(); - const envVars = envVarsResult.success ? envVarsResult.data || [] : []; - const matchingEnvVar = envVars.find((env) => env.name === variable.name); - const assignAction = await this.showMenu<'enter' | 'fragment' | 'env' | 'unset' | 'back'>( - `Choose action for ${formatSnakeCase(variable.name)}:`, - [ - { name: 'Enter value', value: 'enter' }, - { name: 'Use fragment', value: 'fragment' }, - { - name: matchingEnvVar - ? chalk.green(chalk.bold('Use environment variable')) - : 'Use environment variable', - value: 'env' - }, - { name: 'Unset', value: 'unset' } - ] - ); - try { - switch (assignAction) { - case 'enter': - await this.assignValueToVariable(promptId, variable); - break; - case 'fragment': - await this.assignFragmentToVariable(promptId, variable); - break; - case 'env': - await this.assignEnvVarToVariable(promptId, variable); - break; - case 'unset': - await this.unsetVariable(promptId, variable); - break; + const fragmentRef = `${FRAGMENT_PREFIX}${fragment.category}/${fragment.name}`; + return pipe( + fromApiResult(updatePromptVariable(promptId, variable.name, fragmentRef)), + TE.chain(() => + pipe( + fromApiResult(viewFragmentContent(fragment.category, fragment.name)), + TE.map((content) => { + console.log(chalk.green(`Fragment assigned to ${formatSnakeCase(variable.name)}`)); + console.log(chalk.cyan('Fragment content preview:')); + console.log(content.substring(0, 200) + (content.length > 200 ? '...' : '')); + }) + ) + ) + ); + }) + ); +const assignEnvVarToVariable = (promptId: string, variable: PromptVariable): TE.TaskEither => + pipe( + fromApiResult(readEnvVariables()), + TE.chain((envVars) => { + const matchingEnvVars = envVars.filter( + (ev) => + ev.name.toLowerCase().includes(variable.name.toLowerCase()) || + variable.name.toLowerCase().includes(ev.name.toLowerCase()) + ); + return prompts.showMenu('Select an Environment Variable:', [ + ...matchingEnvVars.map((v) => ({ + name: chalk.green(chalk.bold(`${formatSnakeCase(v.name)} (${v.scope}) - Suggested Match`)), + value: v + })), + ...envVars + .filter((v) => !matchingEnvVars.includes(v)) + .map((v) => ({ + name: `${formatSnakeCase(v.name)} (${v.scope})`, + value: v + })) + ]); + }), + TE.chain((selectedEnvVar) => { + if (selectedEnvVar === 'back') { + console.log(chalk.yellow('Environment variable assignment cancelled.')); + return TE.right(undefined); } - } catch (error) { - this.handleError(error, `assigning variable ${variable.name}`); - } - } - - private async assignValueToVariable(promptId: string, variable: Variable): Promise { - console.log(chalk.cyan(`Enter or edit value for ${formatSnakeCase(variable.name)}:`)); - console.log( - chalk.yellow('(An editor will open with the current value. Edit, save, and close the file when done.)') - ); - const currentValue = variable.value || ''; - const value = await this.getMultilineInput(`Value for ${formatSnakeCase(variable.name)}`, currentValue); - const updateResult = await updatePromptVariable(promptId, variable.name, value); - - if (updateResult.success) { - console.log(chalk.green(`Value set for ${formatSnakeCase(variable.name)}`)); - } else { - throw new Error(`Failed to set value for ${formatSnakeCase(variable.name)}: ${updateResult.error}`); - } + const envVarRef = `${ENV_PREFIX}${selectedEnvVar.name}`; + return pipe( + fromApiResult(updatePromptVariable(promptId, variable.name, envVarRef)), + TE.map(() => { + console.log(chalk.green(`Environment variable assigned to ${formatSnakeCase(variable.name)}`)); + console.log(chalk.cyan(`Current value: ${selectedEnvVar.value}`)); + }) + ); + }) + ); +const unsetVariable = (promptId: string, variable: PromptVariable): TE.TaskEither => + pipe( + fromApiResult(updatePromptVariable(promptId, variable.name, '')), + TE.map(() => { + console.log(chalk.green(`Value unset for ${formatSnakeCase(variable.name)}`)); + }) + ); +const unsetAllVariables = (promptId: string): TE.TaskEither => + pipe( + fromApiResult(getPromptDetails(promptId)), + TE.chain((details) => + pipe( + details.variables, + TE.traverseArray((variable) => + pipe( + fromApiResult(updatePromptVariable(promptId, variable.name, '')), + TE.map(() => { + console.log(chalk.green(`Unset ${formatSnakeCase(variable.name)}`)); + }) + ) + ) + ) + ), + TE.map(() => { + console.log(chalk.green('All variables have been unset.')); + }) + ); +const handleVariableAssignment = ( + promptId: string, + variable: PromptVariable, + action: VariableAction +): TE.TaskEither => { + switch (action) { + case 'enter': + return pipe( + assignValueToVariable(promptId, variable), + TE.map(() => false) // Continue in the same prompt + ); + case 'fragment': + return pipe( + assignFragmentToVariable(promptId, variable), + TE.map(() => false) + ); + case 'env': + return pipe( + assignEnvVarToVariable(promptId, variable), + TE.map(() => false) + ); + case 'unset': + return pipe( + unsetVariable(promptId, variable), + TE.map(() => false) + ); + case 'back': + return TE.right(false); + default: + return TE.left(createCommandError('INVALID_ACTION', `Invalid action: ${action}`)); } - - private async assignFragmentToVariable(promptId: string, variable: Variable): Promise { - const fragmentsResult = await this.handleApiResult(await listFragments(), 'Fetched fragments'); - - if (!fragmentsResult) return; - - const selectedFragment = await this.showMenu( - 'Select a fragment: ', - fragmentsResult.map((f) => ({ - name: `${f.category}/${f.name}`, - value: f - })) +}; +const executePrompt = (promptId: string, details: PromptMetadata): TE.TaskEither => + pipe( + TE.Do, + TE.bind('conversationManager', () => TE.right(new ConversationManager(promptId))), + TE.bind('userInputs', () => + pipe( + details.variables, + TE.traverseArray((variable) => + variable.value + ? TE.right([variable.name, variable.value] as const) + : !variable.optional_for_user + ? pipe( + prompts.getMultilineInput(`Enter value for ${formatSnakeCase(variable.name)}:`), + TE.map((value) => [variable.name, value] as const) + ) + : TE.right([variable.name, ' '] as const) + ) + ) + ), + TE.chain(({ conversationManager, userInputs }) => + pipe( + fromApiResult(conversationManager.initializeConversation(Object.fromEntries(userInputs))), + TE.chain(() => continueConversation(conversationManager)) + ) + ) + ); +const continueConversation = (conversationManager: ConversationManager): TE.TaskEither => { + const loop = (): TE.TaskEither => + pipe( + prompts.showMenu('What would you like to do next?', [ + { name: chalk.green(chalk.bold('Continue conversation')), value: 'continue' } + ]), + TE.chain((action) => { + if (action === 'back') return TE.right(undefined); + return pipe( + prompts.getMultilineInput(''), + TE.chain((input) => + pipe( + fromApiResult(conversationManager.continueConversation(input)), + TE.chain(() => loop()) + ) + ) + ); + }) ); - - if (selectedFragment === 'back') { - console.log(chalk.yellow('Fragment assignment cancelled.')); - return; - } - - const fragmentRef = `${FRAGMENT_PREFIX}${selectedFragment.category}/${selectedFragment.name}`; - const updateResult = await updatePromptVariable(promptId, variable.name, fragmentRef); - - if (!updateResult.success) { - throw new Error(`Failed to assign fragment: ${updateResult.error}`); - } - - const contentResult = await this.handleApiResult( - await viewFragmentContent(selectedFragment.category, selectedFragment.name), - 'Fetched fragment content' + return loop(); +}; +const managePromptVariables = (promptId: string): TE.TaskEither => { + const loop = (): TE.TaskEither => + pipe( + TE.Do, + TE.bind('details', () => fromApiResult(getPromptDetails(promptId))), + TE.bind('envVars', () => fromApiResult(readEnvVariables())), + TE.chain(({ details, envVars }) => + pipe( + taskEitherFromPromise( + () => viewPromptDetails(details), + (error) => createCommandError('VIEW_ERROR', 'Failed to view prompt details', error) + ), + TE.chain(() => + prompts.showMenu( + `Select an action for prompt "${chalk.cyan(details.title)}":`, + [ + ...(details.variables.every((v) => v.optional_for_user || v.value) + ? [{ name: chalk.green(chalk.bold('Execute prompt')), value: 'execute' as const }] + : []), + ...details.variables.map((v) => ({ + name: formatVariableName(v, envVars), + value: v + })), + { name: chalk.red('Unset all variables'), value: 'unset_all' as const } + ] + ) + ), + TE.chain((action): TE.TaskEither => { + if (action === 'back') return TE.right(true); + + if (action === 'execute') { + return pipe( + executePrompt(promptId, details), + TE.map(() => false) + ); + } + + if (action === 'unset_all') { + return pipe( + unsetAllVariables(promptId), + TE.map(() => false) + ); + } + return pipe( + prompts.showMenu( + `Choose action for ${formatSnakeCase(action.name)}:`, + createVariableActionChoices(envVars.some((env) => env.name === action.name)) + ), + TE.chain((variableAction) => handleVariableAssignment(promptId, action, variableAction)) + ); + }), + TE.chain((shouldGoBack) => (shouldGoBack ? TE.right(true) : loop())) + ) + ) ); - - if (contentResult) { - console.log(chalk.green(`Fragment assigned to ${formatSnakeCase(variable.name)}`)); - console.log(chalk.cyan('\nFragment content preview:')); - console.log(contentResult.substring(0, 200) + (contentResult.length > 200 ? '...' : '')); - } else { - console.error(chalk.red('Failed to fetch fragment content')); - } - } - - private async assignEnvVarToVariable(promptId: string, variable: Variable): Promise { - const envVarsResult = await readEnvVars(); - - if (!envVarsResult.success) { - throw new Error('Failed to fetch environment variables'); - } - - const envVars = envVarsResult.data || []; - const matchingEnvVars = envVars.filter( - (ev) => - ev.name.toLowerCase().includes(variable.name.toLowerCase()) || - variable.name.toLowerCase().includes(ev.name.toLowerCase()) + return loop(); +}; +const listPromptsByCategory = (categories: Record): TE.TaskEither => { + const loop = (): TE.TaskEither => + pipe( + prompts.showMenu( + 'Select a category:', + Object.keys(categories) + .sort() + .map((category) => ({ + name: formatTitleCase(category), + value: category + })) + ), + TE.chain((category) => { + if (category === 'back') return TE.right(true); + + const promptsWithCategory = categories[category].map((prompt) => ({ + ...prompt, + category + })); + return pipe( + selectAndManagePrompt(promptsWithCategory), + TE.chain((shouldGoBack) => (shouldGoBack ? loop() : TE.right(false))) + ); + }) ); - const selectedEnvVar = await this.showMenu('Select an Environment Variable:', [ - ...matchingEnvVars.map((v) => ({ - name: chalk.green(chalk.bold(`${formatSnakeCase(v.name)} (${v.scope}) - Suggested Match`)), - value: v - })), - ...envVars - .filter((v) => !matchingEnvVars.includes(v)) - .map((v) => ({ - name: `${formatSnakeCase(v.name)} (${v.scope})`, - value: v + return loop(); +}; +const selectAndManagePrompt = ( + promptsList: Array +): TE.TaskEither => { + const loop = (): TE.TaskEither => + pipe( + prompts.showMenu( + 'Select a prompt:', + promptsList.map((p) => ({ + name: `${formatTitleCase(p.category)} / ${chalk.green(p.title)} (ID: ${p.id})`, + value: p })) - ]); - - if (selectedEnvVar === 'back') { - console.log(chalk.yellow('Environment variable assignment cancelled.')); - return; - } - - const envVarRef = `${ENV_PREFIX}${selectedEnvVar.name}`; - const updateResult = await updatePromptVariable(promptId, variable.name, envVarRef); - - if (!updateResult.success) { - throw new Error(`Failed to assign environment variable: ${updateResult.error}`); - } - - console.log(chalk.green(`Environment variable assigned to ${formatSnakeCase(variable.name)}`)); - console.log(chalk.cyan(`Current value: ${selectedEnvVar.value}`)); - } - - private async unsetVariable(promptId: string, variable: Variable): Promise { - const unsetResult = await updatePromptVariable(promptId, variable.name, ''); - - if (unsetResult.success) { - console.log(chalk.green(`Value unset for ${formatSnakeCase(variable.name)}`)); - } else { - throw new Error(`Failed to unset value for ${formatSnakeCase(variable.name)}: ${unsetResult.error}`); - } - } - private async unsetAllVariables(promptId: string): Promise { - const details = await this.handleApiResult(await getPromptDetails(promptId), 'Fetched prompt details'); - - if (!details) return; - - const confirm = await this.confirmAction( - chalk.red('Are you sure you want to unset all variables for this prompt?') - ); - - if (!confirm) { - console.log(chalk.yellow('Operation cancelled.')); - return; - } - - let success = true; - - for (const variable of details.variables) { - try { - const unsetResult = await updatePromptVariable(promptId, variable.name, ''); - - if (!unsetResult.success) { - throw new Error(unsetResult.error); - } - } catch (error) { - this.handleError(error, `unsetting variable ${formatSnakeCase(variable.name)}`); - success = false; - } - } - - if (success) { - console.log(chalk.green('All variables have been unset for this prompt.')); - } else { - console.log(chalk.yellow('Some variables could not be unset. Please check the errors above.')); - } - - await this.pressKeyToContinue(); - } - - private async executePromptWithAssignment(promptId: string): Promise { - try { - const details = await this.handleApiResult(await getPromptDetails(promptId), 'Fetched prompt details'); - - if (!details) return; - - const userInputs: Record = {}; - - for (const variable of details.variables) { - if (variable.value) { - userInputs[variable.name] = variable.value; - console.log( - chalk.green( - `Using value for ${formatSnakeCase(variable.name)}: ${variable.value.substring(0, 30)}...` - ) - ); - } else if (!variable.optional_for_user) { - userInputs[variable.name] = await this.getMultilineInput( - `Enter value for ${formatSnakeCase(variable.name)}:` - ); - } else { - userInputs[variable.name] = ' '; // Note: Required to sanitize user inputs in prompts - } - } - - const conversationManager = new ConversationManager(promptId); - const result = await this.handleApiResult( - await conversationManager.initializeConversation(userInputs), - 'Initialized conversation' - ); - - if (result) { - await this.continueConversation(conversationManager); - } - } catch (error) { - this.handleError(error, 'executing prompt'); - } - } - - private async continueConversation(conversationManager: ConversationManager): Promise { - while (true) { - try { - const nextAction = await this.showMenu<'continue' | 'back'>('What would you like to do next?', [ - { name: chalk.green(chalk.bold('Continue conversation')), value: 'continue' } - ]); - - if (nextAction === 'back') break; - - const userInput = await this.getMultilineInput(chalk.blue('You: ')); - const response = await this.handleApiResult( - await conversationManager.continueConversation(userInput), - 'Continued conversation' + ), + TE.chain((selectedPrompt) => { + if (selectedPrompt === 'back') return TE.right(true); + return pipe( + TE.Do, + TE.bind('id', () => TE.right(selectedPrompt.id)), + TE.chain(({ id }) => managePromptVariables(id)), + TE.chain((shouldGoBack) => (shouldGoBack ? loop() : TE.right(false))) ); + }) + ); + return loop(); +}; +const executePromptsCommand = (): TE.TaskEither => { + const loop = (): TE.TaskEither => + pipe( + fromApiFunction(() => fetchCategories()), + TE.chain((categories) => + pipe( + prompts.showMenu('Select an action:', createPromptMenuChoices()), + TE.chain((action): TE.TaskEither => { + if (action === 'back') { + return TE.right({ completed: true, action }); + } + return pipe( + TE.Do, + TE.bind('result', () => { + switch (action) { + case 'all': + return selectAndManagePrompt(getAllPrompts(categories)); + case 'category': + return listPromptsByCategory(categories); + case 'id': + return selectAndManagePrompt( + getAllPrompts(categories).sort((a, b) => Number(a.id) - Number(b.id)) + ); + default: + return TE.left( + createCommandError('INVALID_ACTION', `Invalid action: ${action}`) + ); + } + }), + TE.map(() => ({ completed: false, action })) + ); + }) + ) + ) + ); + return createCommandLoop(loop); +}; - if (response) { - // Note: The response is not logged here. - // console.log(chalk.green('AI:'), response); - } - } catch (error) { - this.handleError(error, 'continuing conversation'); - await this.pressKeyToContinue(); - } - } - } -} - -export default new PromptCommand(); +export const promptsCommand = createCommand('prompts', 'List and manage prompts', executePromptsCommand); diff --git a/src/cli/commands/settings-command.ts b/src/cli/commands/settings-command.ts index 6e04b45..5a90779 100644 --- a/src/cli/commands/settings-command.ts +++ b/src/cli/commands/settings-command.ts @@ -1,51 +1,85 @@ -import { BaseCommand } from './base-command'; -import ConfigCommand from './config-command'; -import FlushCommand from './flush-command'; -import SyncCommand from './sync-command'; +// settings-command.ts +import { Command } from 'commander'; +import { pipe } from 'fp-ts/lib/function'; +import * as TE from 'fp-ts/lib/TaskEither'; -type SettingsAction = 'config' | 'sync' | 'flush' | 'back'; +import { CommandError, CommandResult, createCommand, createCommandError } from './base-command'; +import { configCommand } from './config-command'; +import { createInteractivePrompts, MenuChoice } from './interactive'; +import { syncCommand } from './sync-command'; +import { flushCommand } from './flush-command'; +import chalk from 'chalk'; -class SettingsCommand extends BaseCommand { - private configCommand: BaseCommand; - private syncCommand: BaseCommand; - private flushCommand: BaseCommand; +// Types +type SettingsAction = 'config' | 'sync' | 'flush' | 'back'; - constructor() { - super('settings', 'Manage CLI configuration'); - this.action(this.execute.bind(this)); +interface SettingsCommandResult extends CommandResult { + readonly action: SettingsAction; +} - this.configCommand = ConfigCommand; - this.syncCommand = SyncCommand; - this.flushCommand = FlushCommand; +// Create interactive prompts instance +const prompts = createInteractivePrompts(); +// Pure functions +const createSettingsChoices = (): ReadonlyArray> => [ + { name: 'Configure CLI', value: 'config' }, + { name: 'Sync with remote repository', value: 'sync' }, + { name: chalk.red('Flush and reset data'), value: 'flush' } +]; +// Command map with proper typing +const commandMap: Record, Command> = { + config: configCommand, + sync: syncCommand, + flush: flushCommand +}; +// Effects +const executeSettingsAction = (action: SettingsAction): TE.TaskEither => { + if (action === 'back') { + return TE.right({ action, completed: true }); } - async execute(): Promise { - while (true) { - try { - const action = await this.showMenu('Settings Menu:', [ - { name: 'Configure CLI', value: 'config' }, - { name: 'Sync with remote repository', value: 'sync' }, - { name: 'Flush and reset data', value: 'flush' } - ]); - switch (action) { - case 'config': - await this.runSubCommand(this.configCommand); - break; - case 'sync': - await this.runSubCommand(this.syncCommand); - break; - case 'flush': - await this.runSubCommand(this.flushCommand); - break; - case 'back': - return; - } - } catch (error) { - this.handleError(error, 'settings menu'); - await this.pressKeyToContinue(); - } - } + const command = commandMap[action]; + + if (!command) { + return TE.left(createCommandError('INVALID_ACTION', `Invalid action: ${action}`)); } -} + return pipe( + TE.tryCatch( + async () => { + await command.parseAsync([], { from: 'user' }); + return { action, completed: false }; + }, + (error) => + createCommandError( + 'COMMAND_EXECUTION_ERROR', + `Failed to execute ${action} command: ${error instanceof Error ? error.message : String(error)}` + ) + ) + ); +}; +const showSettingsMenu = (): TE.TaskEither => + prompts.showMenu('Settings Menu:', createSettingsChoices()); +const handleSettingsAction = (action: SettingsAction): TE.TaskEither => + action === 'back' + ? TE.right({ action, completed: true }) + : pipe( + executeSettingsAction(action), + TE.map(() => ({ action, completed: false })) + ); +// Main command execution +const executeSettingsCommand = (): TE.TaskEither => { + const loop = (): TE.TaskEither => + pipe( + showSettingsMenu(), + TE.chain(handleSettingsAction), + TE.chain((result) => { + if (result.completed) { + return TE.right(result); + } + return loop(); + }) + ); + return loop(); +}; -export default new SettingsCommand(); +// Export settings command +export const settingsCommand = createCommand('settings', 'Manage CLI configuration', executeSettingsCommand); diff --git a/src/cli/commands/sync-command.ts b/src/cli/commands/sync-command.ts index 4bb3cc5..3b6bce0 100644 --- a/src/cli/commands/sync-command.ts +++ b/src/cli/commands/sync-command.ts @@ -1,194 +1,338 @@ import path from 'path'; import chalk from 'chalk'; +import { pipe } from 'fp-ts/lib/function'; +import * as O from 'fp-ts/lib/Option'; +import * as TE from 'fp-ts/lib/TaskEither'; import fs from 'fs-extra'; import simpleGit, { SimpleGit } from 'simple-git'; -import { BaseCommand } from './base-command'; +import { + CommandContext, + CommandError, + CommandResult, + createCommand, + createCommandError, + taskEitherFromPromise +} from './base-command'; +import { createInteractivePrompts, confirmAction } from './interactive'; import { getConfig, setConfig } from '../../shared/config'; import logger from '../../shared/utils/logger'; import { cliConfig } from '../config/cli-config'; import { syncPromptsWithDatabase, cleanupOrphanedData } from '../utils/database'; -class SyncCommand extends BaseCommand { - constructor() { - super('sync', 'Sync prompts with the remote repository'); - this.option('-u, --url ', 'Set the remote repository URL') - .option('--force', 'Force sync without confirmation') - .action(this.execute.bind(this)); - } - - async execute(options: { url?: string; force?: boolean }): Promise { - try { - const repoUrl = await this.getRepoUrl(options.url); - const git: SimpleGit = simpleGit(); - const tempDir = path.join(cliConfig.TEMP_DIR, 'temp_repository'); - await this.cleanupTempDir(tempDir); - await this.cloneRepository(git, repoUrl, tempDir); - - const changes = await this.diffDirectories(getConfig().PROMPTS_DIR, path.join(tempDir, 'prompts')); - const fragmentChanges = await this.diffDirectories( - getConfig().FRAGMENTS_DIR, - path.join(tempDir, 'fragments') - ); +type SyncAction = 'sync' | 'back'; - if (changes.length === 0 && fragmentChanges.length === 0) { - logger.info('No changes detected. Everything is up to date.'); - await fs.remove(tempDir); - return; - } +interface SyncCommandResult extends CommandResult { + readonly action?: SyncAction; +} - this.logChanges(changes, 'Prompts'); - this.logChanges(fragmentChanges, 'Fragments'); +interface FileChange { + readonly type: 'added' | 'modified' | 'deleted'; + readonly path: string; +} - if (!options.force && !(await this.confirmSync())) { - logger.info('Sync cancelled by user.'); - await fs.remove(tempDir); - return; - } +interface SyncOptions { + readonly url?: string; + readonly force?: boolean; +} - await this.performSync(tempDir, changes, fragmentChanges); +const prompts = createInteractivePrompts(); +const logError = (error: CommandError): TE.TaskEither => + pipe( + TE.tryCatch( + () => { + logger.error(`Error: ${error.message}`); - logger.info('Sync completed successfully!'); - } catch (error) { - this.handleError(error, 'sync'); - } finally { - await this.pressKeyToContinue(); - } - } + if (error.context) { + logger.error('Context:', error.context); + } + return Promise.resolve(); + }, + (e) => createCommandError('LOGGING_ERROR', 'Failed to log error', e) + ) + ); +const logProgress = (message: string): TE.TaskEither => + pipe( + TE.tryCatch( + () => { + logger.info(message); + return Promise.resolve(); + }, + (e) => createCommandError('LOGGING_ERROR', 'Failed to log progress', e) + ) + ); +const getRepoUrl = (optionUrl?: string): TE.TaskEither => + pipe( + O.fromNullable(optionUrl || getConfig().REMOTE_REPOSITORY), + O.fold( + () => + pipe( + prompts.getInput('Enter the remote repository URL:'), + TE.chain((url) => { + if (!url.trim()) { + return TE.left(createCommandError('CONFIG_ERROR', 'Repository URL cannot be empty')); + } + return pipe( + taskEitherFromPromise( + () => { + setConfig('REMOTE_REPOSITORY', url); + return Promise.resolve(url); + }, + (error) => createCommandError('CONFIG_ERROR', 'Failed to set repository URL', error) + ) + ); + }) + ), + (url) => TE.right(url) + ) + ); +const cleanup = (tempDir: string): TE.TaskEither => + taskEitherFromPromise( + async () => { + await fs.remove(tempDir); + }, + (error) => createCommandError('CLEANUP_ERROR', 'Failed to cleanup temporary directory', error) + ); +const cleanupWithLogging = (tempDir: string): TE.TaskEither => + pipe( + logProgress('Cleaning up temporary files...'), + TE.chain(() => cleanup(tempDir)), + TE.chainFirst(() => logProgress('Cleanup completed')) + ); +const cloneRepository = (git: SimpleGit, repoUrl: string, tempDir: string): TE.TaskEither => + pipe( + logProgress('Fetching remote data...'), + TE.chain(() => + taskEitherFromPromise( + async () => { + await git.clone(repoUrl, tempDir); + }, + (error) => createCommandError('CLONE_ERROR', 'Failed to clone repository', error) + ) + ) + ); +const traverseDirectory = async ( + currentLocalDir: string, + currentRemoteDir: string, + relativePath: string = '' +): Promise => { + const localFiles: string[] = await fs.readdir(currentLocalDir).catch(() => []); + const remoteFiles: string[] = await fs.readdir(currentRemoteDir).catch(() => []); + const changes: FileChange[] = []; + await Promise.all( + remoteFiles.map(async (file) => { + const localPath = path.join(currentLocalDir, file); + const remotePath = path.join(currentRemoteDir, file); + const currentRelativePath = path.join(relativePath, file); - private async getRepoUrl(optionUrl?: string): Promise { - const config = getConfig(); - let repoUrl = optionUrl || config.REMOTE_REPOSITORY; + if (!localFiles.includes(file)) { + changes.push({ type: 'added', path: currentRelativePath }); + } else { + const remoteStats = await fs.stat(remotePath); - if (!repoUrl) { - repoUrl = await this.getInput('Enter the remote repository URL:'); - setConfig('REMOTE_REPOSITORY', repoUrl); - } - return repoUrl; - } + if (remoteStats.isDirectory()) { + const subChanges = await traverseDirectory(localPath, remotePath, currentRelativePath); + changes.push(...subChanges); + } else { + const [localContent, remoteContent] = await Promise.all([ + fs.readFile(localPath, 'utf-8').catch(() => ''), + fs.readFile(remotePath, 'utf-8').catch(() => '') + ]); - private async cleanupTempDir(tempDir: string): Promise { - logger.info('Cleaning up temporary directory...'); - await fs.remove(tempDir); - } + if (localContent !== remoteContent) { + changes.push({ type: 'modified', path: currentRelativePath }); + } + } + } + }) + ); - private async cloneRepository(git: SimpleGit, repoUrl: string, tempDir: string): Promise { - logger.info('Fetching remote data...'); - await git.clone(repoUrl, tempDir); + localFiles.forEach((file) => { + if (!remoteFiles.includes(file)) { + changes.push({ type: 'deleted', path: path.join(relativePath, file) }); + } + }); + return changes; +}; +const diffDirectories = (localDir: string, remoteDir: string): TE.TaskEither => + taskEitherFromPromise( + () => traverseDirectory(localDir, remoteDir), + (error) => createCommandError('DIFF_ERROR', 'Failed to diff directories', error) + ); +const logChanges = (changes: FileChange[], title: string): void => { + if (changes.length > 0) { + console.log(chalk.bold(`\n${title}:`)); + changes.forEach(({ type, path: filePath }) => { + switch (type) { + case 'added': + console.log(chalk.green(` + ${filePath}`)); + break; + case 'modified': + console.log(chalk.yellow(` * ${filePath}`)); + break; + case 'deleted': + console.log(chalk.red(` - ${filePath}`)); + break; + } + }); } +}; +const syncDirectories = ( + localDir: string, + remoteDir: string, + changes: FileChange[] +): TE.TaskEither => + pipe( + TE.tryCatch( + async () => { + await fs.ensureDir(localDir); - private async diffDirectories(localDir: string, remoteDir: string): Promise> { - const changes: Array<{ type: string; path: string }> = []; - - async function traverseDirectory( - currentLocalDir: string, - currentRemoteDir: string, - relativePath: string = '' - ): Promise { - const localFiles: string[] = await fs.readdir(currentLocalDir).catch(() => []); - const remoteFiles: string[] = await fs.readdir(currentRemoteDir).catch(() => []); + for (const change of changes) { + const localPath = path.join(localDir, change.path); + const remotePath = path.join(remoteDir, change.path); - for (const file of remoteFiles) { - const localPath = path.join(currentLocalDir, file); - const remotePath = path.join(currentRemoteDir, file); - const currentRelativePath = path.join(relativePath, file); + try { + switch (change.type) { + case 'added': + case 'modified': + const dirPath = path.join(localDir, path.dirname(change.path)); + await fs.ensureDir(dirPath); - if (!localFiles.includes(file)) { - changes.push({ type: 'added', path: currentRelativePath }); - } else { - const remoteStats = await fs.stat(remotePath); - - if (remoteStats.isDirectory()) { - await traverseDirectory(localPath, remotePath, currentRelativePath); - } else { - const [localContent, remoteContent] = await Promise.all([ - fs.readFile(localPath, 'utf-8').catch(() => ''), - fs.readFile(remotePath, 'utf-8').catch(() => '') - ]); - - if (localContent !== remoteContent) { - changes.push({ type: 'modified', path: currentRelativePath }); + if (await fs.pathExists(remotePath)) { + await fs.copy(remotePath, localPath, { overwrite: true }); + await pipe(logProgress(`Synced: ${change.path}`), TE.toUnion)(); + } else { + logger.error(`Remote path does not exist: ${remotePath}`); + } + break; + case 'deleted': + await fs.remove(localPath); + await pipe(logProgress(`Removed: ${change.path}`), TE.toUnion)(); + break; } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to sync ${change.path}: ${errorMessage}`); } } + }, + (error) => { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error(`Sync error: ${errorMessage}`); + return createCommandError('SYNC_ERROR', errorMessage); } - - for (const file of localFiles) { - if (!remoteFiles.includes(file)) { - changes.push({ type: 'deleted', path: path.join(relativePath, file) }); - } + ) + ); +const performSync = ( + tempDir: string, + changes: FileChange[], + fragmentChanges: FileChange[] +): TE.TaskEither => + pipe( + TE.Do, + TE.chain(() => logProgress('Starting sync process...')), + TE.chain(() => { + if (changes.length > 0) { + return pipe( + logProgress('Syncing prompts...'), + TE.chain(() => syncDirectories(getConfig().PROMPTS_DIR, path.join(tempDir, 'prompts'), changes)) + ); } - } - - await traverseDirectory(localDir, remoteDir); - return changes; - } - - private logChanges(changes: Array<{ type: string; path: string }>, title: string): void { - if (changes.length > 0) { - console.log(chalk.bold(`\n${title}:`)); - changes.forEach(({ type, path }) => { - switch (type) { - case 'added': - console.log(chalk.green(` + ${path}`)); - break; - case 'modified': - console.log(chalk.yellow(` * ${path}`)); - break; - case 'deleted': - console.log(chalk.red(` - ${path}`)); - break; - } - }); - } - } - - private async confirmSync(): Promise { - return this.confirmAction('Do you want to proceed with the sync?'); - } - - private async performSync( - tempDir: string, - changes: Array<{ type: string; path: string }>, - fragmentChanges: Array<{ type: string; path: string }> - ): Promise { - logger.info('Syncing prompts...'); - await this.syncDirectories(getConfig().PROMPTS_DIR, path.join(tempDir, 'prompts'), changes); - - logger.info('Syncing fragments...'); - await this.syncDirectories(getConfig().FRAGMENTS_DIR, path.join(tempDir, 'fragments'), fragmentChanges); - - logger.info('Updating database...'); - await syncPromptsWithDatabase(); + return TE.right(undefined); + }), + TE.chain(() => { + if (fragmentChanges.length > 0) { + return pipe( + logProgress('Syncing fragments...'), + TE.chain(() => + syncDirectories(getConfig().FRAGMENTS_DIR, path.join(tempDir, 'fragments'), fragmentChanges) + ) + ); + } + return TE.right(undefined); + }), + TE.chain(() => logProgress('Updating database...')), + TE.chain(() => + taskEitherFromPromise( + () => syncPromptsWithDatabase(), + (error) => createCommandError('DATABASE_SYNC_ERROR', `Failed to sync database: ${error}`) + ) + ), + TE.chain(() => logProgress('Cleaning up orphaned data...')), + TE.chain(() => + taskEitherFromPromise( + () => cleanupOrphanedData(), + (error) => createCommandError('CLEANUP_ERROR', `Failed to cleanup orphaned data: ${error}`) + ) + ), + TE.chain(() => cleanupWithLogging(tempDir)), + TE.chain(() => logProgress('Sync completed successfully!')), + TE.orElse((error) => + pipe( + logError(error), + TE.chain(() => cleanupWithLogging(tempDir)), + TE.chain(() => TE.left(error)) + ) + ) + ); - logger.info('Cleaning up orphaned data...'); - await cleanupOrphanedData(); +const executeSyncCommand = (ctx: CommandContext): TE.TaskEither => { + const options = ctx.options as SyncOptions; + const tempDir = path.join(cliConfig.TEMP_DIR, 'temp_repository'); + const git = simpleGit(); + const createResult = (action: SyncAction): SyncCommandResult => ({ + completed: true, + action + }); - logger.info('Removing temporary files...'); - await fs.remove(tempDir); - } + return pipe( + TE.Do, + TE.bind('repoUrl', () => getRepoUrl(options.url)), + TE.chain((result) => { + logger.info(`Using repository URL: ${result.repoUrl}`); + return pipe( + cleanup(tempDir), + TE.chain(() => cloneRepository(git, result.repoUrl, tempDir)) + ); + }), + TE.bind('changes', () => diffDirectories(getConfig().PROMPTS_DIR, path.join(tempDir, 'prompts'))), + TE.bind('fragmentChanges', () => diffDirectories(getConfig().FRAGMENTS_DIR, path.join(tempDir, 'fragments'))), + TE.chain(({ changes, fragmentChanges }) => { + // After flushing, we should always have changes to sync + logChanges(changes, 'Prompts'); + logChanges(fragmentChanges, 'Fragments'); - private async syncDirectories( - localDir: string, - remoteDir: string, - changes: Array<{ type: string; path: string }> - ): Promise { - for (const { type, path: filePath } of changes) { - const localPath = path.join(localDir, filePath); - const remotePath = path.join(remoteDir, filePath); - switch (type) { - case 'added': - case 'modified': - await fs.ensureDir(path.dirname(localPath)); - await fs.copy(remotePath, localPath, { overwrite: true }); - break; - case 'deleted': - await fs.remove(localPath); - break; + if (options.force) { + return pipe( + performSync(tempDir, changes, fragmentChanges), + TE.map(() => createResult('sync')) + ); } - } - } -} -export default new SyncCommand(); + return pipe( + confirmAction('Do you want to proceed with the sync?'), + TE.chain((confirmed) => + confirmed + ? pipe( + performSync(tempDir, changes, fragmentChanges), + TE.map(() => createResult('sync')) + ) + : pipe( + cleanup(tempDir), + TE.map(() => createResult('back')) + ) + ) + ); + }), + TE.orElse((error) => + pipe( + logError(error), + TE.chain(() => cleanup(tempDir)), + TE.chain(() => TE.left(error)) + ) + ) + ); +}; + +export const syncCommand = createCommand('sync', 'Sync prompts with the remote repository', executeSyncCommand); diff --git a/src/cli/config/cli-config.ts b/src/cli/config/cli-config.ts index 4427ee2..5d1af97 100644 --- a/src/cli/config/cli-config.ts +++ b/src/cli/config/cli-config.ts @@ -11,8 +11,8 @@ export interface CliConfig { } export const cliConfig: CliConfig = { - PROMPTS_DIR: 'prompts', - FRAGMENTS_DIR: 'fragments', + PROMPTS_DIR: path.join(CONFIG_DIR, 'prompts'), + FRAGMENTS_DIR: path.join(CONFIG_DIR, 'fragments'), DB_PATH: path.join(CONFIG_DIR, 'prompts.sqlite'), TEMP_DIR: path.join(CONFIG_DIR, 'temp'), MENU_PAGE_SIZE: process.env.MENU_PAGE_SIZE ? parseInt(process.env.MENU_PAGE_SIZE, 10) : 20 diff --git a/src/cli/index.ts b/src/cli/index.ts index 6efd72e..aca66b0 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,49 +1,92 @@ #!/usr/bin/env node import { input } from '@inquirer/prompts'; +import chalk from 'chalk'; import { Command } from 'commander'; import dotenv from 'dotenv'; +import * as E from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/function'; +import * as O from 'fp-ts/lib/Option'; +import * as T from 'fp-ts/lib/Task'; +import * as TE from 'fp-ts/lib/TaskEither'; import { getConfigValue, setConfig } from '../shared/config'; -import configCommand from './commands/config-command'; -import envCommand from './commands/env-command'; -import executeCommand from './commands/execute-command'; -import flushCommand from './commands/flush-command'; -import fragmentsCommand from './commands/fragments-command'; +import { CommandError } from './commands/base-command'; +import { configCommand } from './commands/config-command'; +import { envCommand } from './commands/env-command'; +import { flushCommand } from './commands/flush-command'; +import { fragmentsCommand } from './commands/fragments-command'; import { showMainMenu } from './commands/menu-command'; -import promptsCommand from './commands/prompts-command'; -import settingsCommand from './commands/settings-command'; -import syncCommand from './commands/sync-command'; +import { promptsCommand } from './commands/prompts-command'; +import { settingsCommand } from './commands/settings-command'; +import { syncCommand } from './commands/sync-command'; import { initDatabase } from './utils/database'; +// Set environment process.env.CLI_ENV = 'cli'; - dotenv.config(); -async function ensureApiKey(): Promise { - let apiKey = getConfigValue('ANTHROPIC_API_KEY'); - - if (!apiKey) { - console.log('ANTHROPIC_API_KEY is not set.'); - apiKey = await input({ message: 'Please enter your Anthropic API key:' }); - setConfig('ANTHROPIC_API_KEY', apiKey); - } - - if (!getConfigValue('ANTHROPIC_API_KEY')) { - throw new Error('Failed to set ANTHROPIC_API_KEY'); - } +// Types +interface CliError extends CommandError { + readonly type: 'CLI_ERROR'; } -async function main(): Promise { - await initDatabase(); - await ensureApiKey(); +interface CliConfig { + readonly program: Command; + readonly apiKey: string; +} +// Pure functions for CLI setup +const createCliError = (code: string, message: string): CliError => ({ + type: 'CLI_ERROR', + code, + message +}); +const validateApiKey = (apiKey: string): E.Either => + apiKey.trim() === '' ? E.left(createCliError('INVALID_API_KEY', 'API key cannot be empty')) : E.right(apiKey); +const getStoredApiKey = (): O.Option => + pipe( + O.fromNullable(getConfigValue('ANTHROPIC_API_KEY')), + O.filter((key) => key.trim() !== '') + ); +// Effects +const promptForApiKey = (): TE.TaskEither => + pipe( + TE.tryCatch( + () => input({ message: 'Please enter your Anthropic API key:' }), + (error) => createCliError('API_KEY_INPUT_ERROR', `Failed to get API key input: ${error}`) + ), + TE.chain((key) => pipe(validateApiKey(key), TE.fromEither)) + ); +const setApiKey = (apiKey: string): TE.TaskEither => + pipe( + TE.tryCatch( + () => { + setConfig('ANTHROPIC_API_KEY', apiKey); + return Promise.resolve(apiKey); + }, + (error) => createCliError('API_KEY_SAVE_ERROR', `Failed to save API key: ${error}`) + ) + ); +const ensureApiKey = (): TE.TaskEither => + pipe( + getStoredApiKey(), + O.fold( + () => pipe(promptForApiKey(), TE.chain(setApiKey)), + (apiKey) => TE.right(apiKey) + ) + ); +// Program setup +const createProgram = (): Command => { const program = new Command(); - program.name('prompt-library-cli').description('CLI tool for managing and executing AI prompts').version('1.0.0'); - + return program + .name('prompt-library-cli') + .description('CLI tool for managing and executing AI prompts') + .version('1.0.0'); +}; +const registerCommands = (program: Command): Command => { const commands = [ configCommand, envCommand, - executeCommand, flushCommand, fragmentsCommand, promptsCommand, @@ -51,15 +94,49 @@ async function main(): Promise { syncCommand ]; commands.forEach((cmd) => program.addCommand(cmd)); + return program; +}; +const initializeCli = (): TE.TaskEither => + pipe( + TE.tryCatch( + () => initDatabase(), + (error) => createCliError('DATABASE_INIT_ERROR', `Failed to initialize database: ${error}`) + ), + TE.chain(() => ensureApiKey()), + TE.map((apiKey) => ({ + program: pipe(createProgram(), registerCommands), + apiKey + })) + ); +// Main program flow +const main = async (): Promise => { + await pipe( + initializeCli(), + TE.chain((config) => + TE.tryCatch( + async () => { + if (process.argv.length > 2) { + await config.program.parseAsync(process.argv); + } else { + await showMainMenu(config.program); + } + }, + (error) => createCliError('EXECUTION_ERROR', `Failed to execute CLI: ${error}`) + ) + ), + TE.fold( + (error) => { + console.error(chalk.red(`Error: ${error.message}`)); + return T.of(undefined); + }, + () => T.of(undefined) + ) + )(); +}; - if (process.argv.length > 2) { - await program.parseAsync(process.argv); - } else { - await showMainMenu(program); - } +if (require.main === module) { + main().catch((error) => { + console.error(chalk.red('Fatal error:'), error); + process.exit(1); + }); } - -main().catch((error) => { - console.error('An error occurred:', error); - process.exit(1); -}); diff --git a/src/cli/utils/__tests__/__snapshots__/prompts.test.ts.snap b/src/cli/utils/__tests__/__snapshots__/prompts.test.ts.snap index c636f79..6132939 100644 --- a/src/cli/utils/__tests__/__snapshots__/prompts.test.ts.snap +++ b/src/cli/utils/__tests__/__snapshots__/prompts.test.ts.snap @@ -12,8 +12,7 @@ Tags: tag1, tag2 Options: ([*] Required [ ] Optional) --var1 [*] test role - Env: env_var_name (env-value) -" + Env: env_var_name (env-value)" `; exports[`PromptsUtils viewPromptDetails should display fragment variable correctly 1`] = ` @@ -43,8 +42,7 @@ Options: ([*] Required [ ] Optional) Not Set (Required) --var2 [ ] test role 2 - Not Set -" + Not Set" `; exports[`PromptsUtils viewPromptDetails should display regular variable value correctly 1`] = ` @@ -74,6 +72,5 @@ Options: ([*] Required [ ] Optional) Not Set (Required) --var2 [ ] test role 2 - Not Set -" + Not Set" `; diff --git a/src/cli/utils/__tests__/env-vars.test.ts b/src/cli/utils/__tests__/env-vars.test.ts index 9d2ef18..6840e67 100644 --- a/src/cli/utils/__tests__/env-vars.test.ts +++ b/src/cli/utils/__tests__/env-vars.test.ts @@ -1,6 +1,6 @@ -import { EnvVar } from '../../../shared/types'; +import { EnvVariable } from '../../../shared/types'; import { runAsync, allAsync } from '../database'; -import { createEnvVar, readEnvVars, updateEnvVar, deleteEnvVar } from '../env-vars'; +import { createEnvVariable, readEnvVariables, updateEnvVariable, deleteEnvVariable } from '../env-vars'; jest.mock('../database', () => ({ runAsync: jest.fn(), @@ -14,9 +14,9 @@ describe('EnvVarsUtils', () => { jest.spyOn(console, 'error').mockImplementation(() => {}); }); - describe('createEnvVar', () => { + describe('createEnvVariable', () => { it('should successfully create an environment variable', async () => { - const mockEnvVar: Omit = { + const mockEnvVar: Omit = { name: 'TEST_VAR', value: 'test-value', scope: 'global', @@ -27,7 +27,7 @@ describe('EnvVarsUtils', () => { data: { lastID: 1 } }); - const result = await createEnvVar(mockEnvVar); + const result = await createEnvVariable(mockEnvVar); expect(result).toEqual({ success: true, data: { ...mockEnvVar, id: 1 } @@ -39,7 +39,7 @@ describe('EnvVarsUtils', () => { }); it('should handle database errors during creation', async () => { - const mockEnvVar: Omit = { + const mockEnvVar: Omit = { name: 'TEST_VAR', value: 'test-value', scope: 'global', @@ -47,7 +47,7 @@ describe('EnvVarsUtils', () => { }; (runAsync as jest.Mock).mockRejectedValue(new Error('Database error')); - const result = await createEnvVar(mockEnvVar); + const result = await createEnvVariable(mockEnvVar); expect(result).toEqual({ success: false, error: 'Failed to create environment variable' @@ -55,7 +55,7 @@ describe('EnvVarsUtils', () => { }); }); - describe('readEnvVars', () => { + describe('readEnvVariables', () => { it('should read all global environment variables', async () => { const mockEnvVars = [ { id: 1, name: 'TEST_VAR1', value: 'value1', scope: 'global', prompt_id: null }, @@ -66,7 +66,7 @@ describe('EnvVarsUtils', () => { data: mockEnvVars }); - const result = await readEnvVars(); + const result = await readEnvVariables(); expect(result).toEqual({ success: true, data: mockEnvVars @@ -82,7 +82,7 @@ describe('EnvVarsUtils', () => { data: mockEnvVars }); - const result = await readEnvVars(promptId); + const result = await readEnvVariables(promptId); expect(result).toEqual({ success: true, data: mockEnvVars @@ -96,7 +96,7 @@ describe('EnvVarsUtils', () => { it('should handle database errors during read', async () => { (allAsync as jest.Mock).mockRejectedValue(new Error('Database error')); - const result = await readEnvVars(); + const result = await readEnvVariables(); expect(result).toEqual({ success: false, error: 'Failed to read environment variables' @@ -110,7 +110,7 @@ describe('EnvVarsUtils', () => { error: undefined }); - const result = await readEnvVars(); + const result = await readEnvVariables(); expect(result).toEqual({ success: false, error: 'Failed to fetch environment variables' @@ -118,14 +118,14 @@ describe('EnvVarsUtils', () => { }); }); - describe('updateEnvVar', () => { + describe('updateEnvVariable', () => { it('should successfully update an environment variable', async () => { (runAsync as jest.Mock).mockResolvedValue({ success: true, data: { changes: 1 } }); - const result = await updateEnvVar(1, 'new-value'); + const result = await updateEnvVariable(1, 'new-value'); expect(result).toEqual({ success: true }); expect(runAsync).toHaveBeenCalledWith('UPDATE env_vars SET value = ? WHERE id = ?', ['new-value', 1]); }); @@ -136,7 +136,7 @@ describe('EnvVarsUtils', () => { data: { changes: 0 } }); - const result = await updateEnvVar(999, 'new-value'); + const result = await updateEnvVariable(999, 'new-value'); expect(result).toEqual({ success: false, error: 'No environment variable found with id 999' @@ -146,7 +146,7 @@ describe('EnvVarsUtils', () => { it('should handle database errors during update', async () => { (runAsync as jest.Mock).mockRejectedValue(new Error('Database error')); - const result = await updateEnvVar(1, 'new-value'); + const result = await updateEnvVariable(1, 'new-value'); expect(result).toEqual({ success: false, error: 'Failed to update environment variable' @@ -154,13 +154,13 @@ describe('EnvVarsUtils', () => { }); }); - describe('deleteEnvVar', () => { + describe('deleteEnvVariable', () => { it('should successfully delete an environment variable', async () => { (runAsync as jest.Mock).mockResolvedValue({ success: true }); - const result = await deleteEnvVar(1); + const result = await deleteEnvVariable(1); expect(result).toEqual({ success: true }); expect(runAsync).toHaveBeenCalledWith('DELETE FROM env_vars WHERE id = ?', [1]); }); @@ -168,7 +168,7 @@ describe('EnvVarsUtils', () => { it('should handle database errors during deletion', async () => { (runAsync as jest.Mock).mockRejectedValue(new Error('Database error')); - const result = await deleteEnvVar(1); + const result = await deleteEnvVariable(1); expect(result).toEqual({ success: false, error: 'Failed to delete environment variable' diff --git a/src/cli/utils/__tests__/file-system.test.ts b/src/cli/utils/__tests__/file-system.test.ts index e1f7371..086afdb 100644 --- a/src/cli/utils/__tests__/file-system.test.ts +++ b/src/cli/utils/__tests__/file-system.test.ts @@ -1,18 +1,34 @@ +import { pipe } from 'fp-ts/lib/function'; +import * as TE from 'fp-ts/lib/TaskEither'; import fs from 'fs-extra'; import { readDirectory } from '../../../shared/utils/file-system'; import { cliConfig } from '../../config/cli-config'; -import { hasPrompts, hasFragments } from '../file-system'; +import { hasPrompts, hasFragments, flushDirectories } from '../file-system'; +import { getConfig } from '../../../shared/config'; +import { CommandError } from '../../commands/base-command'; +// Mock setup jest.mock('fs-extra'); jest.mock('../../../shared/utils/file-system'); +jest.mock('../../../shared/config'); +jest.mock('../../../shared/utils/logger'); jest.mock('../errors', () => ({ handleError: jest.fn() })); +type TestResult = { + success: boolean; + error?: CommandError; +}; + describe('FileSystemUtils', () => { beforeEach(() => { jest.clearAllMocks(); + (getConfig as jest.Mock).mockReturnValue({ + PROMPTS_DIR: cliConfig.PROMPTS_DIR, + FRAGMENTS_DIR: cliConfig.FRAGMENTS_DIR + }); }); describe('hasPrompts', () => { @@ -74,4 +90,82 @@ describe('FileSystemUtils', () => { expect(result).toBe(false); }); }); + + describe('flushDirectories', () => { + it('should successfully flush directories', async () => { + (fs.emptyDir as jest.Mock).mockResolvedValue(undefined); + + const result = await pipe( + flushDirectories(), + TE.match( + (error: CommandError): TestResult => ({ success: false, error }), + (): TestResult => ({ success: true }) + ) + )(); + + expect(result.success).toBe(true); + expect(fs.emptyDir).toHaveBeenCalledTimes(2); + expect(fs.emptyDir).toHaveBeenCalledWith(getConfig().PROMPTS_DIR); + expect(fs.emptyDir).toHaveBeenCalledWith(getConfig().FRAGMENTS_DIR); + }); + + it('should handle errors when flushing directories', async () => { + const fsError = new Error('Flush error'); + (fs.emptyDir as jest.Mock).mockRejectedValue(fsError); + + const result = await pipe( + flushDirectories(), + TE.match( + (error: CommandError): TestResult => ({ success: false, error }), + (): TestResult => ({ success: true }) + ) + )(); + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + expect(result.error?.code).toBe('FLUSH_ERROR'); + expect(result.error?.message).toBe('Failed to flush directories'); + expect(result.error?.context).toBe(fsError); + }); + + it('should handle errors for individual directories', async () => { + (fs.emptyDir as jest.Mock) + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('Fragment dir error')); + + const result = await pipe( + flushDirectories(), + TE.match( + (error: CommandError): TestResult => ({ success: false, error }), + (): TestResult => ({ success: true }) + ) + )(); + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + expect(result.error?.code).toBe('FLUSH_ERROR'); + expect(fs.emptyDir).toHaveBeenCalledWith(getConfig().PROMPTS_DIR); + expect(fs.emptyDir).toHaveBeenCalledWith(getConfig().FRAGMENTS_DIR); + }); + + it('should use correct directory paths from config', async () => { + const customConfig = { + PROMPTS_DIR: '/custom/prompts', + FRAGMENTS_DIR: '/custom/fragments' + }; + (getConfig as jest.Mock).mockReturnValue(customConfig); + (fs.emptyDir as jest.Mock).mockResolvedValue(undefined); + + await pipe( + flushDirectories(), + TE.match( + () => void 0, + () => void 0 + ) + )(); + + expect(fs.emptyDir).toHaveBeenCalledWith(customConfig.PROMPTS_DIR); + expect(fs.emptyDir).toHaveBeenCalledWith(customConfig.FRAGMENTS_DIR); + }); + }); }); diff --git a/src/cli/utils/__tests__/fragments.test.ts b/src/cli/utils/__tests__/fragments.test.ts index bf151c5..337ccb7 100644 --- a/src/cli/utils/__tests__/fragments.test.ts +++ b/src/cli/utils/__tests__/fragments.test.ts @@ -2,7 +2,7 @@ import path from 'path'; import { jest } from '@jest/globals'; -import { Fragment } from '../../../shared/types'; +import { PromptFragment } from '../../../shared/types'; import { readDirectory, readFileContent } from '../../../shared/utils/file-system'; import { cliConfig } from '../../config/cli-config'; import { listFragments, viewFragmentContent } from '../fragments'; @@ -26,7 +26,7 @@ describe('FragmentsUtils', () => { .mockResolvedValueOnce(['fragment1.md', 'fragment2.md']) .mockResolvedValueOnce(['fragment3.md']); - const expectedFragments: Fragment[] = [ + const expectedFragments: PromptFragment[] = [ { category: 'category1', name: 'fragment1', variable: '' }, { category: 'category1', name: 'fragment2', variable: '' }, { category: 'category2', name: 'fragment3', variable: '' } @@ -50,7 +50,7 @@ describe('FragmentsUtils', () => { .mockResolvedValueOnce(['category1']) .mockResolvedValueOnce(['fragment1.md', 'fragment2.txt', 'fragment3.md']); - const expectedFragments: Fragment[] = [ + const expectedFragments: PromptFragment[] = [ { category: 'category1', name: 'fragment1', variable: '' }, { category: 'category1', name: 'fragment3', variable: '' } ]; diff --git a/src/cli/utils/__tests__/input-resolver.test.ts b/src/cli/utils/__tests__/input-resolver.test.ts index bc26d12..cea394f 100644 --- a/src/cli/utils/__tests__/input-resolver.test.ts +++ b/src/cli/utils/__tests__/input-resolver.test.ts @@ -1,8 +1,8 @@ import { jest } from '@jest/globals'; -import { EnvVar } from '../../../shared/types'; +import { EnvVariable } from '../../../shared/types'; import { FRAGMENT_PREFIX, ENV_PREFIX } from '../../constants'; -import { readEnvVars } from '../env-vars'; +import { readEnvVariables } from '../env-vars'; import { viewFragmentContent } from '../fragments'; import { resolveValue, resolveInputs } from '../input-resolver'; @@ -13,14 +13,14 @@ jest.mock('../errors', () => ({ })); describe('InputResolverUtils', () => { - const mockReadEnvVars = readEnvVars as jest.MockedFunction; + const mockReadEnvVars = readEnvVariables as jest.MockedFunction; const mockViewFragmentContent = viewFragmentContent as jest.MockedFunction; beforeEach(() => { jest.clearAllMocks(); }); describe('resolveValue', () => { - const mockEnvVars: EnvVar[] = [ + const mockEnvVars: EnvVariable[] = [ { id: 1, name: 'TEST_VAR', value: 'test-value', scope: 'global' }, { id: 2, name: 'NESTED_VAR', value: '$env:TEST_VAR', scope: 'global' } ]; diff --git a/src/cli/utils/__tests__/prompts.test.ts b/src/cli/utils/__tests__/prompts.test.ts index 26a2ef6..7201541 100644 --- a/src/cli/utils/__tests__/prompts.test.ts +++ b/src/cli/utils/__tests__/prompts.test.ts @@ -1,9 +1,9 @@ import { RunResult } from 'sqlite3'; -import { PromptMetadata, Variable } from '../../../shared/types'; +import { PromptMetadata, PromptVariable } from '../../../shared/types'; import { ENV_PREFIX, FRAGMENT_PREFIX } from '../../constants'; import { allAsync, getAsync, runAsync } from '../database'; -import { readEnvVars } from '../env-vars'; +import { readEnvVariables } from '../env-vars'; import { createPrompt, listPrompts, getPromptFiles, getPromptMetadata, viewPromptDetails } from '../prompts'; jest.mock('../database'); @@ -364,7 +364,7 @@ describe('PromptsUtils', () => { }); it('should display prompt details correctly', async () => { - const mockReadEnvVars = readEnvVars as jest.MockedFunction; + const mockReadEnvVars = readEnvVariables as jest.MockedFunction; mockReadEnvVars.mockResolvedValueOnce({ success: true, data: [{ id: 1, name: 'var1', value: 'test-value', scope: 'global' }] @@ -376,7 +376,7 @@ describe('PromptsUtils', () => { }); it('should handle env vars fetch failure', async () => { - const mockReadEnvVars = readEnvVars as jest.MockedFunction; + const mockReadEnvVars = readEnvVariables as jest.MockedFunction; mockReadEnvVars.mockResolvedValueOnce({ success: false, error: 'Failed to read env vars' }); await viewPromptDetails(mockPrompt); @@ -385,7 +385,7 @@ describe('PromptsUtils', () => { }); it('should display fragment variable correctly', async () => { - const mockPromptWithFragment: PromptMetadata & { variables: Variable[] } = { + const mockPromptWithFragment: PromptMetadata & { variables: PromptVariable[] } = { ...mockPrompt, variables: [ { @@ -402,7 +402,7 @@ describe('PromptsUtils', () => { }); it('should display env variable correctly', async () => { - const mockPromptWithEnvVar: PromptMetadata & { variables: Variable[] } = { + const mockPromptWithEnvVar: PromptMetadata & { variables: PromptVariable[] } = { ...mockPrompt, variables: [ { @@ -413,7 +413,7 @@ describe('PromptsUtils', () => { } ] }; - const mockReadEnvVars = readEnvVars as jest.MockedFunction; + const mockReadEnvVars = readEnvVariables as jest.MockedFunction; mockReadEnvVars.mockResolvedValueOnce({ success: true, data: [{ id: 1, name: 'ENV_VAR_NAME', value: 'env-value', scope: 'global' }] @@ -425,7 +425,7 @@ describe('PromptsUtils', () => { }); it('should display regular variable value correctly', async () => { - const mockPromptWithValue: PromptMetadata & { variables: Variable[] } = { + const mockPromptWithValue: PromptMetadata & { variables: PromptVariable[] } = { ...mockPrompt, variables: [ { diff --git a/src/cli/utils/database.ts b/src/cli/utils/database.ts index 3c93a40..10e6363 100644 --- a/src/cli/utils/database.ts +++ b/src/cli/utils/database.ts @@ -8,7 +8,7 @@ import sqlite3, { RunResult } from 'sqlite3'; import { AppError, handleError } from './errors'; import { createPrompt } from './prompts'; import { commonConfig } from '../../shared/config/common-config'; -import { ApiResult, CategoryItem, PromptMetadata, Variable } from '../../shared/types'; +import { ApiResult, CategoryItem, PromptMetadata, PromptVariable } from '../../shared/types'; import { fileExists, readDirectory, readFileContent } from '../../shared/utils/file-system'; import logger from '../../shared/utils/logger'; import { cliConfig } from '../config/cli-config'; @@ -123,7 +123,7 @@ export async function initDatabase(): Promise> { prompt_id INTEGER, category TEXT NOT NULL, name TEXT NOT NULL, - variable TEXT NOT NULL, + variable TEXT NOT NULL DEFAULT '', FOREIGN KEY (prompt_id) REFERENCES prompts (id) )`, `CREATE TABLE IF NOT EXISTS env_vars ( @@ -184,9 +184,9 @@ export async function fetchCategories(): Promise> { +): Promise> { const promptResult = await getAsync('SELECT * FROM prompts WHERE id = ?', [promptId]); - const variablesResult = await allAsync( + const variablesResult = await allAsync( 'SELECT name, role, value, optional_for_user FROM variables WHERE prompt_id = ?', [promptId] ); diff --git a/src/cli/utils/env-vars.ts b/src/cli/utils/env-vars.ts index 529f2c3..78327db 100644 --- a/src/cli/utils/env-vars.ts +++ b/src/cli/utils/env-vars.ts @@ -1,8 +1,8 @@ import { runAsync, allAsync } from './database'; import { handleError } from './errors'; -import { EnvVar, ApiResult } from '../../shared/types'; +import { EnvVariable, ApiResult } from '../../shared/types'; -export async function createEnvVar(envVar: Omit): Promise> { +export async function createEnvVariable(envVar: Omit): Promise> { try { const result = await runAsync('INSERT INTO env_vars (name, value, scope, prompt_id) VALUES (?, ?, ?, ?)', [ envVar.name, @@ -20,7 +20,7 @@ export async function createEnvVar(envVar: Omit): Promise> { +export async function readEnvVariables(promptId?: number): Promise> { try { let query = 'SELECT * FROM env_vars WHERE scope = "global"'; const params: any[] = []; @@ -30,7 +30,7 @@ export async function readEnvVars(promptId?: number): Promise(query, params); + const result = await allAsync(query, params); if (!result.success) { return { success: false, error: result.error || 'Failed to fetch environment variables' }; @@ -42,7 +42,7 @@ export async function readEnvVars(promptId?: number): Promise> { +export async function updateEnvVariable(id: number, newValue: string): Promise> { try { const result = await runAsync('UPDATE env_vars SET value = ? WHERE id = ?', [newValue, id]); @@ -56,7 +56,7 @@ export async function updateEnvVar(id: number, newValue: string): Promise> { +export async function deleteEnvVariable(id: number): Promise> { try { await runAsync('DELETE FROM env_vars WHERE id = ?', [id]); return { success: true }; diff --git a/src/cli/utils/file-system.ts b/src/cli/utils/file-system.ts index c2a6bbe..f187c41 100644 --- a/src/cli/utils/file-system.ts +++ b/src/cli/utils/file-system.ts @@ -1,8 +1,13 @@ +import { pipe } from 'fp-ts/lib/function'; +import * as TE from 'fp-ts/lib/TaskEither'; import fs from 'fs-extra'; +import { CommandError, createCommandError } from '../commands/base-command'; import { handleError } from './errors'; +import { getConfig } from '../../shared/config'; import { readDirectory } from '../../shared/utils/file-system'; import { cliConfig } from '../config/cli-config'; +import logger from '../../shared/utils/logger'; export async function hasPrompts(): Promise { try { @@ -25,3 +30,16 @@ export async function hasFragments(): Promise { return false; } } + +export const flushDirectories = (): TE.TaskEither => + pipe( + TE.tryCatch( + async () => { + logger.info('Flushing local directories...'); + await fs.emptyDir(getConfig().PROMPTS_DIR); + await fs.emptyDir(getConfig().FRAGMENTS_DIR); + logger.info('Local directories flushed successfully'); + }, + (error) => createCommandError('FLUSH_ERROR', 'Failed to flush directories', error) + ) + ); diff --git a/src/cli/utils/fragments.ts b/src/cli/utils/fragments.ts index c991278..cad1b7b 100644 --- a/src/cli/utils/fragments.ts +++ b/src/cli/utils/fragments.ts @@ -1,13 +1,13 @@ import path from 'path'; import { handleError } from './errors'; -import { ApiResult, Fragment } from '../../shared/types'; +import { ApiResult, PromptFragment } from '../../shared/types'; import { readDirectory, readFileContent } from '../../shared/utils/file-system'; import { cliConfig } from '../config/cli-config'; -export async function listFragments(): Promise> { +export async function listFragments(): Promise> { try { - const fragments: Fragment[] = []; + const fragments: PromptFragment[] = []; const categories = await readDirectory(cliConfig.FRAGMENTS_DIR); for (const category of categories) { diff --git a/src/cli/utils/input-resolver.ts b/src/cli/utils/input-resolver.ts index cd881a7..5b9e559 100644 --- a/src/cli/utils/input-resolver.ts +++ b/src/cli/utils/input-resolver.ts @@ -1,11 +1,11 @@ -import { EnvVar } from '../../shared/types'; +import { EnvVariable } from '../../shared/types'; import logger from '../../shared/utils/logger'; import { FRAGMENT_PREFIX, ENV_PREFIX } from '../constants'; -import { readEnvVars } from './env-vars'; +import { readEnvVariables } from './env-vars'; import { handleError } from './errors'; import { viewFragmentContent } from './fragments'; -export async function resolveValue(value: string, envVars: EnvVar[]): Promise { +export async function resolveValue(value: string, envVars: EnvVariable[]): Promise { if (value.startsWith(FRAGMENT_PREFIX)) { const [category, name] = value.split(FRAGMENT_PREFIX)[1].split('/'); const fragmentResult = await viewFragmentContent(category, name); @@ -37,7 +37,7 @@ export async function resolveValue(value: string, envVars: EnvVar[]): Promise): Promise> { try { - const envVarsResult = await readEnvVars(); + const envVarsResult = await readEnvVariables(); const envVars = envVarsResult.success ? envVarsResult.data || [] : []; const resolvedInputs: Record = {}; diff --git a/src/cli/utils/prompts.ts b/src/cli/utils/prompts.ts index 5c187d5..6d12696 100644 --- a/src/cli/utils/prompts.ts +++ b/src/cli/utils/prompts.ts @@ -2,10 +2,10 @@ import chalk from 'chalk'; import { allAsync, getAsync, runAsync } from './database'; import { handleError } from './errors'; -import { ApiResult, Fragment, PromptMetadata, Variable } from '../../shared/types'; +import { ApiResult, PromptFragment, PromptMetadata, PromptVariable } from '../../shared/types'; import { formatSnakeCase, formatTitleCase } from '../../shared/utils/string-formatter'; import { FRAGMENT_PREFIX, ENV_PREFIX } from '../constants'; -import { readEnvVars } from './env-vars'; +import { readEnvVariables } from './env-vars'; export async function createPrompt(promptMetadata: PromptMetadata, content: string): Promise> { try { @@ -124,7 +124,7 @@ export async function getPromptMetadata(promptId: string): Promise( + const variablesResult = await allAsync( 'SELECT name, role, optional_for_user, value FROM variables WHERE prompt_id = ?', [promptId] ); @@ -133,7 +133,7 @@ export async function getPromptMetadata(promptId: string): Promise( + const fragmentsResult = await allAsync( 'SELECT category, name, variable FROM fragments WHERE prompt_id = ?', [promptId] ); @@ -162,10 +162,7 @@ export async function getPromptMetadata(promptId: string): Promise { +export async function viewPromptDetails(details: PromptMetadata, isExecute = false): Promise { console.log(chalk.cyan('Prompt:'), details.title); console.log(`\n${details.description || ''}`); console.log(chalk.cyan('\nCategory:'), formatTitleCase(details.primary_category)); @@ -182,7 +179,7 @@ export async function viewPromptDetails( const maxNameLength = Math.max(...details.variables.map((v) => formatSnakeCase(v.name).length)); try { - const envVarsResult = await readEnvVars(); + const envVarsResult = await readEnvVariables(); const envVars = envVarsResult.success ? envVarsResult.data || [] : []; for (const variable of details.variables) { @@ -223,10 +220,6 @@ export async function viewPromptDetails( console.log(` ${status}`); } } - - if (!isExecute) { - console.log(); - } } catch (error) { handleError(error, 'viewing prompt details'); } diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 58ed1bd..991b5ed 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -1,4 +1,4 @@ -export interface EnvVar { +export interface EnvVariable { id: number; name: string; value: string; @@ -15,7 +15,7 @@ export interface CategoryItem { subcategories: string[]; } -export interface Variable { +export interface PromptVariable { name: string; role: string; optional_for_user: boolean; @@ -37,13 +37,13 @@ export interface PromptMetadata { tags: string | string[]; one_line_description: string; description: string; - variables: Variable[]; + variables: PromptVariable[]; content_hash?: string; - fragments?: Fragment[]; + fragments?: PromptFragment[]; } -export interface Fragment { +export interface PromptFragment { name: string; category: string; - variable: string; + variable?: string; } diff --git a/src/shared/utils/prompt-processing.ts b/src/shared/utils/prompt-processing.ts index b93ab2d..1a1a9d6 100644 --- a/src/shared/utils/prompt-processing.ts +++ b/src/shared/utils/prompt-processing.ts @@ -56,9 +56,9 @@ export async function processPromptContent( try { if (logging) { - console.log(chalk.blue(chalk.bold('\nYou:'))); + console.log(chalk.blue(chalk.bold('You:'))); console.log(messages[messages.length - 1]?.content); - console.log(chalk.green(chalk.bold('\nAI:'))); + console.log(chalk.green(chalk.bold('AI:'))); } if (useStreaming) { @@ -92,6 +92,7 @@ async function processStreamingResponse(messages: { role: string; content: strin } } } + console.log(); } catch (error) { handleError(error, 'processing CLI prompt content'); throw error;