diff --git a/package-lock.json b/package-lock.json index cd5f755..5840f2d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "cli-spinner": "0.2.10", "commander": "12.1.0", "dotenv": "16.4.5", + "fp-ts": "^2.16.9", "fs-extra": "11.2.0", "js-yaml": "4.1.0", "nunjucks": "3.2.4", @@ -4836,6 +4837,12 @@ "node": ">= 12.20" } }, + "node_modules/fp-ts": { + "version": "2.16.9", + "resolved": "https://registry.npmjs.org/fp-ts/-/fp-ts-2.16.9.tgz", + "integrity": "sha512-+I2+FnVB+tVaxcYyQkHUq7ZdKScaBlX53A41mxQtpIccsfyv8PzdzP7fzp2AY832T4aoK6UZ5WRX/ebGd8uZuQ==", + "license": "MIT" + }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", diff --git a/package.json b/package.json index 558bc77..e720ee0 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "cli-spinner": "0.2.10", "commander": "12.1.0", "dotenv": "16.4.5", + "fp-ts": "2.16.9", "fs-extra": "11.2.0", "js-yaml": "4.1.0", "nunjucks": "3.2.4", diff --git a/src/cli/commands/base-command.ts b/src/cli/commands/base-command.ts index 9ee9d50..97a3448 100644 --- a/src/cli/commands/base-command.ts +++ b/src/cli/commands/base-command.ts @@ -1,12 +1,13 @@ // command.ts -import { pipe } from 'fp-ts/lib/function'; +import { Command } from 'commander'; +import * as A from 'fp-ts/lib/Array'; import * as E from 'fp-ts/lib/Either'; -import * as TE from 'fp-ts/lib/TaskEither'; +import { pipe } from 'fp-ts/lib/function'; +import * as O from 'fp-ts/lib/Option'; import * as T from 'fp-ts/lib/Task'; -import { Command } from 'commander'; +import * as TE from 'fp-ts/lib/TaskEither'; + import { ApiResult } from '../../shared/types'; -import * as O from 'fp-ts/lib/Option'; -import * as A from 'fp-ts/lib/Array'; // Core types export type CommandContext = { @@ -27,6 +28,7 @@ export interface BaseCommand { } export type CommandResult = E.Either; + export type CommandTask = TE.TaskEither; // Command creation helper @@ -43,7 +45,6 @@ export const createCommand = ( args: args.slice(0, -1), options: args[args.length - 1] || {} }; - await pipe( execute(ctx), TE.fold( @@ -63,7 +64,6 @@ export const createCommand = ( ) )(); }); - return command; }; @@ -120,6 +120,7 @@ export const taskEitherFromPromise = ( ): TE.TaskEither => TE.tryCatch(async () => { const result = await promise(); + if (result === undefined) { throw new Error('Unexpected undefined result'); } @@ -131,6 +132,7 @@ export const fromApiResult = (promise: Promise>): TE.TaskEither< TE.tryCatch( async () => { const result = await promise; + if (!result.success) { throw new Error(result.error || 'Operation failed'); } @@ -145,6 +147,7 @@ export const fromApiFunction = (fn: () => Promise>): TE.TaskEith TE.tryCatch( async () => { const result = await fn(); + if (!result.success || result.data === undefined) { throw new Error(result.error || 'Operation failed'); } diff --git a/src/cli/commands/env-command.ts b/src/cli/commands/env-command.ts index 8107ccc..f46f959 100644 --- a/src/cli/commands/env-command.ts +++ b/src/cli/commands/env-command.ts @@ -1,28 +1,20 @@ // env-command.ts -import { pipe } from 'fp-ts/lib/function'; -import * as TE from 'fp-ts/lib/TaskEither'; +import chalk from 'chalk'; import * as A from 'fp-ts/lib/Array'; -import * as O from 'fp-ts/lib/Option'; import * as Eq from 'fp-ts/lib/Eq'; +import { pipe } from 'fp-ts/lib/function'; +import * as O from 'fp-ts/lib/Option'; import * as Ord from 'fp-ts/lib/Ord'; -import chalk from 'chalk'; +import * as TE from 'fp-ts/lib/TaskEither'; -import { - CommandContext, - CommandError, - createCommand, - createCommandError, - fromApiResult, - fromApiFunction, - traverseArray -} from './base-command'; +import { CommandError, createCommand, fromApiResult, fromApiFunction, traverseArray } from './base-command'; import { EnvVar, Fragment, Variable } 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 { listFragments, viewFragmentContent } from '../utils/fragments'; import { listPrompts, getPromptFiles } from '../utils/prompts'; -import { createInteractivePrompts, MenuChoice } from './interactive'; // Types type EnvAction = 'enter' | 'fragment' | 'unset' | 'back'; @@ -38,34 +30,33 @@ interface EnvCommandResult { // Create interactive prompts instance const prompts = createInteractivePrompts(); - // Pure functions const formatVariableChoices = ( allVariables: ReadonlyArray, envVars: ReadonlyArray ): ReadonlyArray> => { const maxNameLength = Math.max(...allVariables.map((v) => formatSnakeCase(v.name).length)); - return allVariables.map((variable) => { const formattedName = formatSnakeCase(variable.name); - const paddedName = formattedName.padEnd(maxNameLength); + const coloredName = chalk.cyan(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: `${chalk.cyan(paddedName)}: ${status}`, + name: `${coloredName}${paddedSpaces}: ${status}`, value: variable }; }); }; - const getVariableStatus = (envVar: EnvVar | undefined): string => { if (!envVar) return chalk.yellow('Not Set'); - if (envVar.value.startsWith(FRAGMENT_PREFIX)) return chalk.blue(envVar.value); - return chalk.green(`Set: ${envVar.value.substring(0, 20)}${envVar.value.length > 20 ? '...' : ''}`); -}; + 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> => pipe( @@ -89,7 +80,6 @@ const getAllUniqueVariables = (): TE.TaskEither): TE.TaskEither => pipe( prompts.getMultilineInput( @@ -103,6 +93,12 @@ 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( envVar, O.fold( @@ -133,24 +129,23 @@ const enterValueForVariable = (variable: EnvVariable, envVar: O.Option): return operation; }) ); - const assignFragmentToVariable = (variable: EnvVariable): TE.TaskEither => pipe( fromApiResult(listFragments()), TE.chain((fragments) => - prompts.showMenu( - 'Select a fragment:', - fragments.map((f) => ({ + prompts.showMenu('Select a fragment:', [ + ...fragments.map((f) => ({ name: `${formatTitleCase(f.category)} / ${chalk.blue(f.name)}`, value: f })) - ) + ]) ), TE.chain((fragment) => { if (fragment === 'back') { console.log(chalk.yellow('Fragment assignment cancelled.')); return TE.right(undefined); } + const fragmentRef = `${FRAGMENT_PREFIX}${fragment.category}/${fragment.name}`; return pipe( fromApiResult(readEnvVars()), @@ -194,7 +189,6 @@ const assignFragmentToVariable = (variable: EnvVariable): TE.TaskEither): TE.TaskEither => pipe( envVar, @@ -209,8 +203,7 @@ const unsetVariable = (variable: EnvVariable, envVar: O.Option): TE.Task ) ) ); - -const executeEnvCommand = (ctx: CommandContext): TE.TaskEither => { +const executeEnvCommand = (): TE.TaskEither => { const loop = (): TE.TaskEither => pipe( TE.Do, @@ -220,28 +213,24 @@ const executeEnvCommand = (ctx: CommandContext): TE.TaskEither( 'Select a variable to manage:', - [ - ...formatVariableChoices(variables, envVars), - ], + formatVariableChoices(variables, envVars) ), TE.chain((selectedVariable): TE.TaskEither => { if (selectedVariable === 'back') { console.log(chalk.yellow('Returning to main menu.')); return TE.right({ completed: true }); } - return pipe( prompts.showMenu( `Choose action for ${formatSnakeCase(selectedVariable.name)}:`, [ { name: 'Enter value', value: 'enter' }, { name: 'Use fragment', value: 'fragment' }, - { name: 'Unset', value: 'unset' }, - ], + { name: 'Unset', value: 'unset' } + ] ), TE.chain((action) => { const envVar = O.fromNullable(envVars.find((v) => v.name === selectedVariable.name)); - switch (action) { case 'enter': return pipe( @@ -271,7 +260,6 @@ const executeEnvCommand = (ctx: CommandContext): TE.TaskEither { diff --git a/src/cli/commands/interactive.ts b/src/cli/commands/interactive.ts index 821b53e..a81af19 100644 --- a/src/cli/commands/interactive.ts +++ b/src/cli/commands/interactive.ts @@ -1,13 +1,15 @@ +import os from 'os'; +import path from 'path'; + import { editor, input, select } from '@inquirer/prompts'; +import chalk from 'chalk'; +import { pipe } from 'fp-ts/lib/function'; import * as TE from 'fp-ts/lib/TaskEither'; +import fs from 'fs-extra'; + import { CommandError, createCommandError } from '../commands/base-command'; import { cliConfig } from '../config/cli-config'; -import chalk from 'chalk'; -import { pipe } from 'fp-ts/lib/function'; import { ENV_PREFIX, FRAGMENT_PREFIX } from '../constants'; -import path from 'path'; -import os from 'os'; -import fs from 'fs-extra'; export interface MenuChoice { readonly name: string; @@ -46,7 +48,6 @@ export const createInteractivePrompts = (): InteractivePrompts => ({ clearConsole = true, pageSize = cliConfig.MENU_PAGE_SIZE } = options; - return TE.tryCatch( async () => { if (clearConsole) { @@ -61,7 +62,6 @@ export const createInteractivePrompts = (): InteractivePrompts => ({ value: goBackValue }); } - return select({ message, choices: menuChoices, @@ -94,9 +94,7 @@ export const createInteractivePrompts = (): InteractivePrompts => ({ initialValue.startsWith(FRAGMENT_PREFIX) || initialValue.startsWith(ENV_PREFIX) ? '' : initialValue; - await fs.writeFile(tempFilePath, cleanedInitialValue); - return editor({ message: 'Edit your input', default: cleanedInitialValue, diff --git a/src/cli/commands/menu-command.ts b/src/cli/commands/menu-command.ts index f605b3a..b53e0a0 100644 --- a/src/cli/commands/menu-command.ts +++ b/src/cli/commands/menu-command.ts @@ -1,14 +1,14 @@ // menu-command.ts import chalk from 'chalk'; import { Command } from 'commander'; -import * as TE from 'fp-ts/lib/TaskEither'; -import * as T from 'fp-ts/lib/Task'; import { pipe } from 'fp-ts/lib/function'; +import * as T from 'fp-ts/lib/Task'; +import * as TE from 'fp-ts/lib/TaskEither'; -import { getConfig } from '../../shared/config'; -import { hasFragments, hasPrompts } from '../utils/file-system'; import { CommandError, taskEitherFromPromise, createCommandError, CommandContext, createCommand } from './base-command'; import { createInteractivePrompts, MenuChoice } from './interactive'; +import { getConfig } from '../../shared/config'; +import { hasFragments, hasPrompts } from '../utils/file-system'; // Types type MenuAction = 'sync' | 'prompts' | 'fragments' | 'settings' | 'env' | 'back'; @@ -20,7 +20,6 @@ interface MenuCommandResult { // Create interactive prompts instance const prompts = createInteractivePrompts(); - // Pure functions const createMenuChoices = ( hasRemoteRepo: boolean, @@ -41,10 +40,8 @@ const createMenuChoices = ( { name: 'Manage environment variables', value: 'env' }, { name: 'Settings', value: 'settings' } ); - return choices; }; - // Effects const checkContentStatus = (): TE.TaskEither => pipe( @@ -54,15 +51,16 @@ const checkContentStatus = (): TE.TaskEither => ), TE.map(([promptsExist, fragmentsExist]) => promptsExist || fragmentsExist) ); - const executeMenuAction = (program: Command, action: MenuAction): TE.TaskEither => pipe( TE.tryCatch( async () => { const command = program.commands.find((cmd) => cmd.name() === action); + if (!command) { throw new Error(`Command '${action}' not found`); } + await command.parseAsync([], { from: 'user' }); }, (error) => @@ -73,7 +71,6 @@ 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 ?')))} @@ -84,13 +81,11 @@ Select an action:` createMenuChoices(hasRemoteRepo, hasExistingContent), { goBackLabel: 'Exit' } ); - // Helper function to create menu results const createMenuResult = (action: MenuAction, completed: boolean): MenuCommandResult => ({ action, completed }); - // Main menu loop const handleExit = (): TE.TaskEither => TE.tryCatch( @@ -103,7 +98,6 @@ const handleExit = (): TE.TaskEither => }, (error) => createCommandError('MENU_ERROR', 'Failed to handle exit', error) ); - const handleMenuAction = ( program: Command, action: Exclude @@ -115,18 +109,15 @@ const handleMenuAction = ( completed: false })) ); - const runMenuLoop = (program: Command): TE.TaskEither => { const config = getConfig(); const hasRemoteRepo = Boolean(config.REMOTE_REPOSITORY); - return pipe( checkContentStatus(), TE.chain((hasExistingContent) => showMenuPrompt(hasRemoteRepo, hasExistingContent)), TE.chain((action) => (action === 'back' ? handleExit() : handleMenuAction(program, action))) ); }; - // Command execution const executeMenuCommand = (ctx: CommandContext & { program: Command }): TE.TaskEither => { const loop = (): TE.TaskEither => @@ -139,7 +130,6 @@ const executeMenuCommand = (ctx: CommandContext & { program: Command }): TE.Task return loop(); }) ); - return loop(); }; diff --git a/src/cli/index.ts b/src/cli/index.ts index bdda1a5..1e4120b 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,20 +1,20 @@ #!/usr/bin/env node import { input } from '@inquirer/prompts'; +import chalk from 'chalk'; import { Command } from 'commander'; import dotenv from 'dotenv'; -import * as TE from 'fp-ts/lib/TaskEither'; -import * as T from 'fp-ts/lib/Task'; -import { pipe } from 'fp-ts/lib/function'; import * as E from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/function'; import * as O from 'fp-ts/lib/Option'; -import chalk from 'chalk'; +import * as T from 'fp-ts/lib/Task'; +import * as TE from 'fp-ts/lib/TaskEither'; import { getConfigValue, setConfig } from '../shared/config'; -import { initDatabase } from './utils/database'; import { CommandError } from './commands/base-command'; -import { showMainMenu } from './commands/menu-command'; -import { envCommand } from './commands/env-command'; import { configCommand } from './commands/config-command'; +import { envCommand } from './commands/env-command'; +import { showMainMenu } from './commands/menu-command'; +import { initDatabase } from './utils/database'; // Set environment process.env.CLI_ENV = 'cli'; @@ -36,16 +36,13 @@ const createCliError = (code: string, message: string): CliError => ({ code, message }); - const validateApiKey = (apiKey: string): E.Either => apiKey.trim() === '' ? E.left(createCliError('INVALID_API_KEY', 'API key cannot be empty')) : E.right(apiKey); - const getStoredApiKey = (): O.Option => pipe( O.fromNullable(getConfigValue('ANTHROPIC_API_KEY')), O.filter((key) => key.trim() !== '') ); - // Effects const promptForApiKey = (): TE.TaskEither => pipe( @@ -55,7 +52,6 @@ const promptForApiKey = (): TE.TaskEither => ), TE.chain((key) => pipe(validateApiKey(key), TE.fromEither)) ); - const setApiKey = (apiKey: string): TE.TaskEither => pipe( TE.tryCatch( @@ -66,7 +62,6 @@ const setApiKey = (apiKey: string): TE.TaskEither => (error) => createCliError('API_KEY_SAVE_ERROR', `Failed to save API key: ${error}`) ) ); - const ensureApiKey = (): TE.TaskEither => pipe( getStoredApiKey(), @@ -75,7 +70,6 @@ const ensureApiKey = (): TE.TaskEither => (apiKey) => TE.right(apiKey) ) ); - // Program setup const createProgram = (): Command => { const program = new Command(); @@ -84,13 +78,11 @@ const createProgram = (): Command => { .description('CLI tool for managing and executing AI prompts') .version('1.0.0'); }; - const registerCommands = (program: Command): Command => { const commands = [configCommand, envCommand]; commands.forEach((cmd) => program.addCommand(cmd)); return program; }; - const initializeCli = (): TE.TaskEither => pipe( TE.tryCatch( @@ -103,7 +95,6 @@ const initializeCli = (): TE.TaskEither => apiKey })) ); - // Main program flow const main = async (): Promise => { await pipe( @@ -130,7 +121,6 @@ const main = async (): Promise => { )(); }; -// Execute program if (require.main === module) { main().catch((error) => { console.error(chalk.red('Fatal error:'), error);