From b334ac28a8b230b7ae43cd7e9935990af547dd12 Mon Sep 17 00:00:00 2001 From: Thibault YOU Date: Sun, 27 Oct 2024 22:40:51 +0100 Subject: [PATCH] :test_tube: --- eslint.config.mjs | 2 +- src/app/utils/yaml-operations.ts | 6 +- src/cli/commands/base-command.ts | 47 +- src/cli/commands/config-command.ts | 175 ++-- src/cli/commands/env-command.ts | 117 ++- src/cli/commands/execute-command.ts | 486 +++++----- src/cli/commands/flush-command.ts | 70 +- src/cli/commands/fragments-command.ts | 216 ++--- src/cli/commands/interactive.ts | 4 +- src/cli/commands/menu-command.ts | 47 +- src/cli/commands/prompts-command.ts | 849 ++++++++---------- src/cli/commands/settings-command.ts | 120 ++- src/cli/commands/sync-command.ts | 464 ++++++---- src/cli/index.ts | 5 +- .../__snapshots__/prompts.test.ts.snap | 9 +- src/cli/utils/__tests__/env-vars.test.ts | 38 +- src/cli/utils/__tests__/fragments.test.ts | 6 +- .../utils/__tests__/input-resolver.test.ts | 8 +- src/cli/utils/__tests__/prompts.test.ts | 16 +- src/cli/utils/database.ts | 8 +- src/cli/utils/env-vars.ts | 12 +- src/cli/utils/fragments.ts | 6 +- src/cli/utils/input-resolver.ts | 8 +- src/cli/utils/prompts.ts | 19 +- src/shared/types/index.ts | 12 +- src/shared/utils/prompt-processing.ts | 5 +- 26 files changed, 1456 insertions(+), 1299 deletions(-) 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/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 97a3448..33275f8 100644 --- a/src/cli/commands/base-command.ts +++ b/src/cli/commands/base-command.ts @@ -1,4 +1,3 @@ -// command.ts import { Command } from 'commander'; import * as A from 'fp-ts/lib/Array'; import * as E from 'fp-ts/lib/Either'; @@ -21,18 +20,23 @@ export type CommandError = { readonly context?: unknown; }; +export interface CommandResult { + readonly completed: boolean; + readonly action?: string; +} + export interface BaseCommand { readonly name: string; readonly description: string; readonly execute: (ctx: CommandContext) => Promise>; } -export type CommandResult = E.Either; +// export type CommandResult = E.Either; export type CommandTask = TE.TaskEither; // Command creation helper -export const createCommand = ( +export const createCommand = ( name: string, description: string, execute: (ctx: CommandContext) => TE.TaskEither @@ -54,19 +58,25 @@ export const createCommand = ( throw new Error(`Failed to execute ${name} command: ${error.message}`); })() ), - (value: A) => - T.of( - (() => { - console.log('Command Result:', value); - return value; - })() - ) + (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 pipe(loopFn(), TE.chain(runLoop)); + }; + return runLoop(); +}; + // Helper for handling command results export const handleCommandResult = ( result: A, @@ -75,11 +85,9 @@ export const handleCommandResult = ( silent?: boolean; } ): void => { - if (result !== undefined && !options?.silent) { + if (!options?.silent) { if (options?.onSuccess) { options.onSuccess(result); - } else { - console.log(result); } } }; @@ -120,11 +128,7 @@ export const taskEitherFromPromise = ( ): TE.TaskEither => TE.tryCatch(async () => { const result = await promise(); - - if (result === undefined) { - throw new Error('Unexpected undefined result'); - } - return result; + return result as A; // Allow undefined/void returns }, errorMapper); // Helper for converting ApiResult to TaskEither @@ -136,8 +140,7 @@ export const fromApiResult = (promise: Promise>): TE.TaskEither< if (!result.success) { throw new Error(result.error || 'Operation failed'); } - // Handle the case where data might be undefined but success is true - return result.data !== undefined ? result.data : (null as any); + return result.data as A; // Allow undefined data }, (error) => createCommandError('API_ERROR', String(error)) ); @@ -148,10 +151,10 @@ export const fromApiFunction = (fn: () => Promise>): TE.TaskEith async () => { const result = await fn(); - if (!result.success || result.data === undefined) { + if (!result.success) { throw new Error(result.error || 'Operation failed'); } - return result.data; + return result.data as A; // Allow undefined data }, (error) => createCommandError('API_ERROR', String(error)) ); 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 f46f959..ff4be2c 100644 --- a/src/cli/commands/env-command.ts +++ b/src/cli/commands/env-command.ts @@ -1,4 +1,3 @@ -// env-command.ts import chalk from 'chalk'; import * as A from 'fp-ts/lib/Array'; import * as Eq from 'fp-ts/lib/Eq'; @@ -7,58 +6,61 @@ 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 } from './base-command'; -import { EnvVar, Fragment, Variable } from '../../shared/types'; +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 { createInteractivePrompts, MenuChoice } from './interactive'; -import { createEnvVar, readEnvVars, updateEnvVar, deleteEnvVar } from '../utils/env-vars'; +import { createEnvVariable, updateEnvVariable, deleteEnvVariable, readEnvVariables } from '../utils/env-vars'; import { listFragments, viewFragmentContent } from '../utils/fragments'; import { listPrompts, getPromptFiles } from '../utils/prompts'; // Types type EnvAction = 'enter' | 'fragment' | 'unset' | 'back'; -interface EnvVariable { - readonly name: string; - readonly role: string; +interface EnvCommandResult extends CommandResult { + readonly action?: EnvAction; } -interface EnvCommandResult { - readonly completed: boolean; -} - -// Create interactive prompts instance +// Instances const prompts = createInteractivePrompts(); -// Pure functions +// 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> => { + 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 = chalk.cyan(formattedName); + 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}`, + name: `${coloredName}${paddedSpaces} -> ${status}`, value: variable }; }); }; -const getVariableStatus = (envVar: EnvVar | undefined): string => { - if (!envVar) return chalk.yellow('Not Set'); - - const trimmedValue = envVar.value.trim(); - - if (trimmedValue.startsWith(FRAGMENT_PREFIX)) return chalk.blue(trimmedValue); - return chalk.green(`Set: ${trimmedValue.substring(0, 20)}${trimmedValue.length > 20 ? '...' : ''}`); -}; -// Effects -const getAllUniqueVariables = (): TE.TaskEither> => +// Variable management +const getAllUniqueVariables = (): TE.TaskEither> => pipe( fromApiFunction(() => listPrompts()), TE.chain((prompts) => @@ -74,13 +76,16 @@ const getAllUniqueVariables = (): TE.TaskEither pipe( variables.flat(), - A.uniq(Eq.fromEquals((a, b) => a.name === b.name)), - A.map((v) => ({ name: v.name, role: v.role })), - A.sort(Ord.fromCompare((a, b) => a.name.localeCompare(b.name) as -1 | 0 | 1)) + 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)) ) ) ); -const enterValueForVariable = (variable: EnvVariable, envVar: O.Option): TE.TaskEither => +// Variable actions +const enterValueForVariable = ( + variable: PromptVariable, + envVar: O.Option +): TE.TaskEither => pipe( prompts.getMultilineInput( `Value for ${formatSnakeCase(variable.name)}`, @@ -93,19 +98,17 @@ const enterValueForVariable = (variable: EnvVariable, envVar: O.Option): ) ), TE.chain((value) => { - // Check if the user canceled the input (e.g., empty string) if (value.trim() === '') { console.log(chalk.yellow('Input canceled. Returning to actions menu.')); return TE.right(undefined); } - - const operation = pipe( + return pipe( envVar, O.fold( () => pipe( fromApiResult( - createEnvVar({ + createEnvVariable({ name: variable.name, value, scope: 'global' @@ -119,21 +122,20 @@ const enterValueForVariable = (variable: EnvVariable, envVar: O.Option): ), (ev) => pipe( - fromApiResult(updateEnvVar(ev.id, value)), + fromApiResult(updateEnvVariable(ev.id, value)), TE.map(() => { console.log(chalk.green(`Updated value for ${formatSnakeCase(variable.name)}`)); }) ) ) ); - return operation; }) ); -const assignFragmentToVariable = (variable: EnvVariable): TE.TaskEither => +const assignFragmentToVariable = (variable: PromptVariable): TE.TaskEither => pipe( fromApiResult(listFragments()), TE.chain((fragments) => - prompts.showMenu('Select a fragment:', [ + prompts.showMenu('Select a fragment:', [ ...fragments.map((f) => ({ name: `${formatTitleCase(f.category)} / ${chalk.blue(f.name)}`, value: f @@ -148,16 +150,16 @@ const assignFragmentToVariable = (variable: EnvVariable): TE.TaskEither { const existingEnvVar = envVars.find((v) => v.name === variable.name); - const updateOperation = pipe( + return pipe( O.fromNullable(existingEnvVar), O.fold( () => pipe( fromApiResult( - createEnvVar({ + createEnvVariable({ name: variable.name, value: fragmentRef, scope: 'global' @@ -167,12 +169,11 @@ const assignFragmentToVariable = (variable: EnvVariable): TE.TaskEither pipe( - fromApiResult(updateEnvVar(ev.id, fragmentRef)), + fromApiResult(updateEnvVariable(ev.id, fragmentRef)), TE.map(() => undefined) ) ) ); - return updateOperation; }), TE.chain(() => pipe( @@ -189,29 +190,30 @@ const assignFragmentToVariable = (variable: EnvVariable): TE.TaskEither): TE.TaskEither => +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(deleteEnvVar(ev.id)), + 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(() => readEnvVars())), + TE.bind('envVars', () => fromApiFunction(() => readEnvVariables())), TE.chain(({ variables, envVars }) => pipe( - prompts.showMenu( + prompts.showMenu( 'Select a variable to manage:', formatVariableChoices(variables, envVars) ), @@ -260,20 +262,7 @@ const executeEnvCommand = (): TE.TaskEither => { ) ) ); - return pipe( - loop(), - TE.chain((result) => { - if (result.completed) { - return TE.right(result); - } - return loop(); - }), - TE.map((finalResult) => { - console.log('Exiting env command.'); - return finalResult; - }) - ); + return createCommandLoop(loop); }; -// Export command -export const envCommand = createCommand('env', 'Manage global environment variables', executeEnvCommand); +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..e2b7341 100644 --- a/src/cli/commands/flush-command.ts +++ b/src/cli/commands/flush-command.ts @@ -1,41 +1,41 @@ -import chalk from 'chalk'; +// import chalk from 'chalk'; -import { BaseCommand } from './base-command'; -import { flushData } from '../utils/database'; +// import { BaseCommand } from './base-command'; +// import { flushData } from '../utils/database'; -class FlushCommand extends BaseCommand { - constructor() { - super('flush', 'Flush and reset all data (preserves config)'); - this.action(this.execute.bind(this)); - } +// 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.') - ); +// 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(); - } - } +// 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(); - console.log(chalk.green('Data flushed successfully. The CLI will now exit.')); - process.exit(0); - } catch (error) { - this.handleError(error, 'flushing data'); - } - } -} +// private async performFlush(): Promise { +// try { +// await flushData(); +// console.log(chalk.green('Data flushed successfully. The CLI will now exit.')); +// process.exit(0); +// } catch (error) { +// this.handleError(error, 'flushing data'); +// } +// } +// } -export default new FlushCommand(); +// export default new FlushCommand(); diff --git a/src/cli/commands/fragments-command.ts b/src/cli/commands/fragments-command.ts index 0f36130..0520979 100644 --- a/src/cli/commands/fragments-command.ts +++ b/src/cli/commands/fragments-command.ts @@ -1,108 +1,108 @@ -import chalk from 'chalk'; - -import { BaseCommand } from './base-command'; -import { Fragment } from '../../shared/types'; -import { formatTitleCase } from '../../shared/utils/string-formatter'; -import { listFragments, viewFragmentContent } from '../utils/fragments'; - -type FragmentMenuAction = 'all' | 'category' | 'back'; - -class FragmentsCommand extends BaseCommand { - constructor() { - super('fragments', 'List and view fragments'); - this.action(this.execute.bind(this)); - } - - 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' } - ]); - - if (action === 'back') { - return; - } - - 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}`) - ); - 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(); - } - } -} - -export default new FragmentsCommand(); +// import chalk from 'chalk'; + +// import { BaseCommand } from './base-command'; +// import { Fragment } from '../../shared/types'; +// import { formatTitleCase } from '../../shared/utils/string-formatter'; +// import { listFragments, viewFragmentContent } from '../utils/fragments'; + +// type FragmentMenuAction = 'all' | 'category' | 'back'; + +// class FragmentsCommand extends BaseCommand { +// constructor() { +// super('fragments', 'List and view fragments'); +// this.action(this.execute.bind(this)); +// } + +// 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' } +// ]); + +// if (action === 'back') { +// return; +// } + +// 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}`) +// ); +// 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(); +// } +// } +// } + +// export default new FragmentsCommand(); diff --git a/src/cli/commands/interactive.ts b/src/cli/commands/interactive.ts index a81af19..6dd7f8f 100644 --- a/src/cli/commands/interactive.ts +++ b/src/cli/commands/interactive.ts @@ -33,6 +33,7 @@ export interface InteractivePrompts { readonly getInput: ( message: string, + initialValue?: string, validate?: (input: string) => boolean | string ) => TE.TaskEither; @@ -72,11 +73,12 @@ export const createInteractivePrompts = (): InteractivePrompts => ({ ); }, - getInput: (message: string, validate?) => + 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) diff --git a/src/cli/commands/menu-command.ts b/src/cli/commands/menu-command.ts index b53e0a0..930f5d7 100644 --- a/src/cli/commands/menu-command.ts +++ b/src/cli/commands/menu-command.ts @@ -5,7 +5,14 @@ import { pipe } from 'fp-ts/lib/function'; import * as T from 'fp-ts/lib/Task'; import * as TE from 'fp-ts/lib/TaskEither'; -import { CommandError, taskEitherFromPromise, createCommandError, CommandContext, createCommand } from './base-command'; +import { + CommandError, + taskEitherFromPromise, + createCommandError, + CommandContext, + createCommand, + CommandResult +} from './base-command'; import { createInteractivePrompts, MenuChoice } from './interactive'; import { getConfig } from '../../shared/config'; import { hasFragments, hasPrompts } from '../utils/file-system'; @@ -13,9 +20,8 @@ import { hasFragments, hasPrompts } from '../utils/file-system'; // Types type MenuAction = 'sync' | 'prompts' | 'fragments' | 'settings' | 'env' | 'back'; -interface MenuCommandResult { - readonly action: MenuAction; - readonly completed: boolean; +interface MenuCommandResult extends CommandResult { + readonly action?: MenuAction; } // Create interactive prompts instance @@ -71,21 +77,16 @@ const executeMenuAction = (program: Command, action: MenuAction): TE.TaskEither< ) ) ); -const showMenuPrompt = (hasRemoteRepo: boolean, hasExistingContent: boolean): TE.TaskEither => - prompts.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:` -)}`, - createMenuChoices(hasRemoteRepo, hasExistingContent), - { goBackLabel: 'Exit' } - ); -// Helper function to create menu results -const createMenuResult = (action: MenuAction, completed: boolean): MenuCommandResult => ({ - action, - completed -}); +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( @@ -119,13 +120,15 @@ const runMenuLoop = (program: Command): TE.TaskEither => { - const loop = (): TE.TaskEither => +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(undefined); + return TE.right(result); } return loop(); }) 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..0a10c45 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'; -class SettingsCommand extends BaseCommand { - private configCommand: BaseCommand; - private syncCommand: BaseCommand; - private flushCommand: BaseCommand; +// Types +// type SettingsAction = 'config' | 'sync' | 'flush' | 'back'; +type SettingsAction = 'config' | 'sync' | '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..85334a0 100644 --- a/src/cli/commands/sync-command.ts +++ b/src/cli/commands/sync-command.ts @@ -1,194 +1,336 @@ 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') - ); - - if (changes.length === 0 && fragmentChanges.length === 0) { - logger.info('No changes detected. Everything is up to date.'); - await fs.remove(tempDir); - return; - } - - this.logChanges(changes, 'Prompts'); - this.logChanges(fragmentChanges, 'Fragments'); +type SyncAction = 'sync' | 'back'; - if (!options.force && !(await this.confirmSync())) { - logger.info('Sync cancelled by user.'); - await fs.remove(tempDir); - return; - } - - await this.performSync(tempDir, changes, fragmentChanges); - - logger.info('Sync completed successfully!'); - } catch (error) { - this.handleError(error, 'sync'); - } finally { - await this.pressKeyToContinue(); - } - } - - private async getRepoUrl(optionUrl?: string): Promise { - const config = getConfig(); - let repoUrl = optionUrl || config.REMOTE_REPOSITORY; - - if (!repoUrl) { - repoUrl = await this.getInput('Enter the remote repository URL:'); - setConfig('REMOTE_REPOSITORY', repoUrl); - } - return repoUrl; - } +interface SyncCommandResult extends CommandResult { + readonly action?: SyncAction; +} - private async cleanupTempDir(tempDir: string): Promise { - logger.info('Cleaning up temporary directory...'); - await fs.remove(tempDir); - } +interface FileChange { + readonly type: 'added' | 'modified' | 'deleted'; + readonly path: string; +} - private async cloneRepository(git: SimpleGit, repoUrl: string, tempDir: string): Promise { - logger.info('Fetching remote data...'); - await git.clone(repoUrl, tempDir); - } +interface SyncOptions { + readonly url?: string; + readonly force?: boolean; +} - private async diffDirectories(localDir: string, remoteDir: string): Promise> { - const changes: Array<{ type: string; path: string }> = []; +const prompts = createInteractivePrompts(); +const logError = (error: CommandError): TE.TaskEither => + pipe( + TE.tryCatch( + () => { + logger.error(`Error: ${error.message}`); - 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(() => []); + 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); - for (const file of remoteFiles) { - const localPath = path.join(currentLocalDir, file); - const remotePath = path.join(currentRemoteDir, file); - const currentRelativePath = path.join(relativePath, file); + if (!localFiles.includes(file)) { + changes.push({ type: 'added', path: currentRelativePath }); + } else { + const remoteStats = await fs.stat(remotePath); - if (!localFiles.includes(file)) { - changes.push({ type: 'added', path: currentRelativePath }); + if (remoteStats.isDirectory()) { + const subChanges = await traverseDirectory(localPath, remotePath, currentRelativePath); + changes.push(...subChanges); } 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 }); - } + 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 }); } } } + }) + ); - for (const file of localFiles) { - if (!remoteFiles.includes(file)) { - changes.push({ type: 'deleted', path: path.join(relativePath, file) }); - } - } + localFiles.forEach((file) => { + if (!remoteFiles.includes(file)) { + changes.push({ type: 'deleted', path: path.join(relativePath, file) }); } - - 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(); - - logger.info('Cleaning up orphaned data...'); - await cleanupOrphanedData(); - - logger.info('Removing temporary files...'); - await fs.remove(tempDir); - } - - 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); + }); + 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': - await fs.ensureDir(path.dirname(localPath)); - await fs.copy(remotePath, localPath, { overwrite: true }); + console.log(chalk.yellow(` * ${filePath}`)); break; case 'deleted': - await fs.remove(localPath); + console.log(chalk.red(` - ${filePath}`)); break; } - } + }); } -} +}; +const syncDirectories = ( + localDir: string, + remoteDir: string, + changes: FileChange[] +): TE.TaskEither => + pipe( + TE.tryCatch( + async () => { + await fs.ensureDir(localDir); + + for (const change of changes) { + const localPath = path.join(localDir, change.path); + const remotePath = path.join(remoteDir, change.path); + + try { + switch (change.type) { + case 'added': + case 'modified': + await fs.ensureDir(path.dirname(localPath)); + await fs.copy(remotePath, localPath, { overwrite: true }); + await pipe(logProgress(`Synced: ${change.path}`), TE.toUnion)(); + 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); + } + ) + ); +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)) + ); + } + 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(() => + taskEitherFromPromise( + async () => { + await pipe(logProgress('Updating database...'), TE.toUnion)(); + await syncPromptsWithDatabase(); + await pipe(logProgress('Cleaning up orphaned data...'), TE.toUnion)(); + await cleanupOrphanedData(); + await pipe(cleanupWithLogging(tempDir), TE.toUnion)(); + await pipe(logProgress('Sync completed successfully!'), TE.toUnion)(); + }, + (error) => { + const errorMessage = error instanceof Error ? error.message : String(error); + return createCommandError('SYNC_ERROR', `Failed to complete sync process: ${errorMessage}`); + } + ) + ), + TE.orElse((error) => + pipe( + logError(error), + TE.chain(() => cleanupWithLogging(tempDir)), + TE.chain(() => TE.left(error)) + ) + ) + ); +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 + }); + 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 }) => { + if (changes.length === 0 && fragmentChanges.length === 0) { + logger.info('No changes detected. Everything is up to date.'); + return pipe( + cleanup(tempDir), + TE.map(() => createResult('sync')) + ); + } + + logChanges(changes, 'Prompts'); + logChanges(fragmentChanges, 'Fragments'); + + if (options.force) { + return pipe( + performSync(tempDir, changes, fragmentChanges), + TE.map(() => createResult('sync')) + ); + } + 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 default new SyncCommand(); +export const syncCommand = createCommand('sync', 'Sync prompts with the remote repository', executeSyncCommand); diff --git a/src/cli/index.ts b/src/cli/index.ts index 1e4120b..67c381a 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -14,6 +14,9 @@ import { CommandError } from './commands/base-command'; import { configCommand } from './commands/config-command'; import { envCommand } from './commands/env-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 { initDatabase } from './utils/database'; // Set environment @@ -79,7 +82,7 @@ const createProgram = (): Command => { .version('1.0.0'); }; const registerCommands = (program: Command): Command => { - const commands = [configCommand, envCommand]; + const commands = [configCommand, envCommand, promptsCommand, settingsCommand, syncCommand]; commands.forEach((cmd) => program.addCommand(cmd)); return program; }; 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__/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/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;