diff --git a/.env.test b/.env.test
new file mode 100644
index 0000000..d440e73
--- /dev/null
+++ b/.env.test
@@ -0,0 +1,5 @@
+ANTHROPIC_API_KEY=test-anthropic-key
+FORCE_REGENERATE=false
+CLI_ENV=cli
+NODE_ENV=test
+LOG_LEVEL=error
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 7f13a33..defb0e9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,10 +5,14 @@
dist/
node_modules/
archive/
+coverage/
# Ignore local database
*.sqlite
+# Ignore aider files
+.aider*
+
# Ignore macOS files
.DS_Store
diff --git a/README.md b/README.md
index a0d7918..6f4378d 100644
--- a/README.md
+++ b/README.md
@@ -6,25 +6,8 @@ Welcome to the **Prompt Library**, a collection of categorized AI prompts for ea
## 📚 Table of Contents
-
-
-
-- [🎯 Purpose & Features](#-purpose--features)
-- [⚡ Quick Start](#-quick-start)
-- [🛠️ How It Works](#-how-it-works)
-- [🖥️ CLI Usage](#-cli-usage)
- - [Interactive Menu](#interactive-menu)
- - [List Prompts and Categories](#list-prompts-and-categories)
- - [Sync Personal Library](#sync-personal-library)
- - [Execute Prompts](#execute-prompts)
-- [📂 Prompt Library Example](#-prompt-library-example)
-- [🚀 Getting Started](#-getting-started)
-- [🧩 Using Fragments](#-using-fragments)
-- [⚙️ Metadata Customization](#-metadata-customization)
-- [🤝 Contributing](#-contributing)
-- [📄 License](#-license)
-
-
+
+
## 🎯 Purpose & Features
@@ -128,9 +111,9 @@ prompt-library-cli execute --help
- [Git Branch Name Generator](prompts/git_branch_name_generator/README.md) - Generates optimized git branch names based on project context and user requirements
- [Git Commit Message Agent](prompts/git_commit_message_agent/README.md) - Generates precise and informative git commit messages following Conventional Commits specification
- [GitHub Issue Creator](prompts/github_issue_creator_agent/README.md) - Creates comprehensive and actionable GitHub issues based on provided project information
-- [Software Architect Visionary](prompts/software_architect_agent/README.md) - Analyzes user requirements and creates comprehensive software specification documents
- [Software Architect Code Reviewer](prompts/software_architect_code_reviewer/README.md) - Generates comprehensive pull requests with architectural analysis and optimization suggestions
- [Software Architect Specification Creator](prompts/software_architect_spec_creator/README.md) - Creates comprehensive software specification documents based on user requirements
+- [Software Architect Visionary](prompts/software_architect_agent/README.md) - Analyzes user requirements and creates comprehensive software specification documents
- [Software Development Expert Agent](prompts/software_dev_expert_agent/README.md) - Provides expert, adaptive assistance across all aspects of the software development lifecycle.
@@ -143,8 +126,8 @@ prompt-library-cli execute --help
Healthcare
-- [Psychological Support and Therapy Agent](prompts/psychological_support_agent/README.md) - Provides AI-driven psychological support and therapy through digital platforms
- [Health Optimization Agent](prompts/health_optimization_agent/README.md) - Generates personalized, adaptive health optimization plans based on comprehensive user data analysis
+- [Psychological Support and Therapy Agent](prompts/psychological_support_agent/README.md) - Provides AI-driven psychological support and therapy through digital platforms
diff --git a/jest.config.js b/jest.config.js
index c924a13..2044cd6 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -1,10 +1,21 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
- testMatch: ['**/tests/**/*.test.ts'],
- globals: {
- 'ts-jest': {
+ setupFiles: ['/jest.setup.ts'],
+ testMatch: ['/src/**/__tests__/**/*.test.ts'],
+ moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
+ moduleNameMapper: {
+ '^@/(.*)$': '/src/$1'
+ },
+ transform: {
+ '^.+\\.ts?$': ['ts-jest', {
tsconfig: 'tsconfig.test.json'
- }
- }
-};
+ }]
+ },
+ collectCoverage: true,
+ coverageDirectory: 'coverage',
+ coveragePathIgnorePatterns: [
+ '/node_modules/',
+ '/dist/'
+ ]
+};
\ No newline at end of file
diff --git a/jest.setup.ts b/jest.setup.ts
new file mode 100644
index 0000000..2a7040e
--- /dev/null
+++ b/jest.setup.ts
@@ -0,0 +1,10 @@
+import * as path from 'path';
+import dotenv from 'dotenv';
+
+const originalEnv = { ...process.env };
+const envTestPath = path.resolve(__dirname, '.env.test');
+dotenv.config({ path: envTestPath });
+
+process.env.NODE_ENV = 'test';
+
+export { originalEnv };
diff --git a/package-lock.json b/package-lock.json
index e5191c8..cd5f755 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,7 +12,7 @@
"@anthropic-ai/sdk": "0.29.1",
"@inquirer/prompts": "7.0.0",
"chalk": "4.1.2",
- "cli-spinner": "^0.2.10",
+ "cli-spinner": "0.2.10",
"commander": "12.1.0",
"dotenv": "16.4.5",
"fs-extra": "11.2.0",
@@ -26,9 +26,11 @@
},
"devDependencies": {
"@eslint/compat": "1.2.0",
+ "@jest/globals": "29.7.0",
+ "@testing-library/jest-dom": "6.4.2",
"@types/fs-extra": "11.0.4",
"@types/inquirer": "9.0.7",
- "@types/jest": "29.5.13",
+ "@types/jest": "29.5.14",
"@types/js-yaml": "4.0.9",
"@types/node": "22.7.6",
"@types/node-cache": "4.2.5",
@@ -44,6 +46,8 @@
"eslint-plugin-simple-import-sort": "12.1.1",
"eslint-plugin-unused-imports": "4.1.4",
"jest": "29.7.0",
+ "jest-environment-node": "29.7.0",
+ "mock-fs": "5.2.0",
"npm-check-updates": "17.1.4",
"prettier": "3.3.3",
"ts-jest": "29.2.5",
@@ -52,6 +56,13 @@
"yaml-lint": "1.7.0"
}
},
+ "node_modules/@adobe/css-tools": {
+ "version": "4.4.0",
+ "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.0.tgz",
+ "integrity": "sha512-Ff9+ksdQQB3rMncgqDK78uLznstjyfIf2Arnh22pW8kBpLs6rpKDwgnZT46hin5Hl1WzazzK64DOrhSwYpS7bQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@ampproject/remapping": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
@@ -654,6 +665,19 @@
"@babel/core": "^7.0.0-0"
}
},
+ "node_modules/@babel/runtime": {
+ "version": "7.25.9",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.9.tgz",
+ "integrity": "sha512-4zpTHZ9Cm6L9L+uIqghQX8ZXg8HKFcjYO3qHoO8zTmRm6HQUJ8SSJ+KRvbMBZn0EGVlT4DRYeQ/6hjlyXBh+Kg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "regenerator-runtime": "^0.14.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/template": {
"version": "7.25.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.7.tgz",
@@ -1747,6 +1771,66 @@
"@sinonjs/commons": "^3.0.0"
}
},
+ "node_modules/@testing-library/jest-dom": {
+ "version": "6.4.2",
+ "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.4.2.tgz",
+ "integrity": "sha512-CzqH0AFymEMG48CpzXFriYYkOjk6ZGPCLMhW9e9jg3KMCn5OfJecF8GtGW7yGfR/IgCe3SX8BSwjdzI6BBbZLw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@adobe/css-tools": "^4.3.2",
+ "@babel/runtime": "^7.9.2",
+ "aria-query": "^5.0.0",
+ "chalk": "^3.0.0",
+ "css.escape": "^1.5.1",
+ "dom-accessibility-api": "^0.6.3",
+ "lodash": "^4.17.15",
+ "redent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=14",
+ "npm": ">=6",
+ "yarn": ">=1"
+ },
+ "peerDependencies": {
+ "@jest/globals": ">= 28",
+ "@types/bun": "latest",
+ "@types/jest": ">= 28",
+ "jest": ">= 28",
+ "vitest": ">= 0.32"
+ },
+ "peerDependenciesMeta": {
+ "@jest/globals": {
+ "optional": true
+ },
+ "@types/bun": {
+ "optional": true
+ },
+ "@types/jest": {
+ "optional": true
+ },
+ "jest": {
+ "optional": true
+ },
+ "vitest": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@testing-library/jest-dom/node_modules/chalk": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz",
+ "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/@textlint/ast-node-types": {
"version": "12.6.1",
"resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-12.6.1.tgz",
@@ -1922,9 +2006,9 @@
}
},
"node_modules/@types/jest": {
- "version": "29.5.13",
- "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.13.tgz",
- "integrity": "sha512-wd+MVEZCHt23V0/L642O5APvspWply/rGY5BcW4SUETo2UzPU3Z26qr8jC2qxpimI2jjx9h7+2cj2FwIr01bXg==",
+ "version": "29.5.14",
+ "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz",
+ "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2496,6 +2580,16 @@
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
+ "node_modules/aria-query": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
+ "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
"node_modules/array-buffer-byte-length": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz",
@@ -3367,6 +3461,13 @@
"node": ">= 8"
}
},
+ "node_modules/css.escape": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/data-view-buffer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz",
@@ -3616,6 +3717,13 @@
"doctoc": "doctoc.js"
}
},
+ "node_modules/dom-accessibility-api": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
+ "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/dom-serializer": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz",
@@ -5289,8 +5397,8 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
"integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+ "devOptional": true,
"license": "MIT",
- "optional": true,
"engines": {
"node": ">=8"
}
@@ -6580,6 +6688,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/lodash": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/lodash.memoize": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
@@ -7089,6 +7204,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/min-indent": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
+ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -7236,6 +7361,16 @@
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
+ "node_modules/mock-fs": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.2.0.tgz",
+ "integrity": "sha512-2dF2R6YMSZbpip1V1WHKGLNjr/k48uQClqMVb5H3MOvwc9qhYis3/IWbj02qIg/Y8MDXKFF4c5v0rxx2o6xTZw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -8229,6 +8364,27 @@
"node": ">= 6"
}
},
+ "node_modules/redent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
+ "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "indent-string": "^4.0.0",
+ "strip-indent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/regenerator-runtime": {
+ "version": "0.14.1",
+ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
+ "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/regexp.prototype.flags": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.3.tgz",
@@ -8959,6 +9115,19 @@
"node": ">=6"
}
},
+ "node_modules/strip-indent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
+ "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "min-indent": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
diff --git a/package.json b/package.json
index e7e8b5a..558bc77 100644
--- a/package.json
+++ b/package.json
@@ -16,13 +16,14 @@
"lint:fix": "npm run lint -- --fix",
"prettify": "prettier --write 'src/**/*.ts'",
"start": "node dist/cli/index.js",
- "test": "jest --passWithNoTests",
+ "test": "jest",
"test:watch": "jest --watch",
+ "test:coverage": "jest --coverage",
"toc": "doctoc README.md --github --notitle",
"type-check": "tsc --noEmit",
"update": "ncu -i",
- "update-metadata": "ts-node src/app/core/update_metadata.ts",
- "update-views": "ts-node src/app/core/update_views.ts",
+ "update-metadata": "ts-node src/app/controllers/update-metadata.ts",
+ "update-views": "ts-node src/app/controllers/update-views.ts",
"validate-yaml": "yamllint '**/*.yml'"
},
"keywords": [
@@ -45,7 +46,7 @@
"@anthropic-ai/sdk": "0.29.1",
"@inquirer/prompts": "7.0.0",
"chalk": "4.1.2",
- "cli-spinner": "^0.2.10",
+ "cli-spinner": "0.2.10",
"commander": "12.1.0",
"dotenv": "16.4.5",
"fs-extra": "11.2.0",
@@ -56,9 +57,11 @@
},
"devDependencies": {
"@eslint/compat": "1.2.0",
+ "@jest/globals": "29.7.0",
+ "@testing-library/jest-dom": "6.4.2",
"@types/fs-extra": "11.0.4",
"@types/inquirer": "9.0.7",
- "@types/jest": "29.5.13",
+ "@types/jest": "29.5.14",
"@types/js-yaml": "4.0.9",
"@types/node": "22.7.6",
"@types/node-cache": "4.2.5",
@@ -74,6 +77,8 @@
"eslint-plugin-simple-import-sort": "12.1.1",
"eslint-plugin-unused-imports": "4.1.4",
"jest": "29.7.0",
+ "jest-environment-node": "29.7.0",
+ "mock-fs": "5.2.0",
"npm-check-updates": "17.1.4",
"prettier": "3.3.3",
"ts-jest": "29.2.5",
diff --git a/prompts/software_architect_code_reviewer/README.md b/prompts/software_architect_code_reviewer/README.md
index 14d159e..2a6fbc1 100644
--- a/prompts/software_architect_code_reviewer/README.md
+++ b/prompts/software_architect_code_reviewer/README.md
@@ -19,7 +19,6 @@ This prompt simulates a world-class software architect and code reviewer, tasked
### 🧩 Relevant Fragments
This prompt could potentially use the following fragments:
-- [Prompt Engineering Guidelines Max](/fragments/prompt_engineering/prompt_engineering_guidelines_max.md) - Could be used into `{{EXTRA_GUIDELINES_OR_CONTEXT}}`
- [Safety Guidelines](/fragments/prompt_engineering/safety_guidelines.md) - Could be used into `{{SAFETY_GUIDELINES}}`
- [Behavior Attributes](/fragments/prompt_engineering/behavior_attributes.md) - Could be used into `{{AI_BEHAVIOR_ATTRIBUTES}}`
diff --git a/prompts/software_architect_code_reviewer/metadata.yml b/prompts/software_architect_code_reviewer/metadata.yml
index f33113b..960d243 100644
--- a/prompts/software_architect_code_reviewer/metadata.yml
+++ b/prompts/software_architect_code_reviewer/metadata.yml
@@ -6,9 +6,6 @@ description: >-
innovative improvements to elevate entire codebases.
directory: software_architect_code_reviewer
fragments:
- - category: prompt_engineering
- name: prompt_engineering_guidelines_max
- variable: '{{EXTRA_GUIDELINES_OR_CONTEXT}}'
- category: prompt_engineering
name: safety_guidelines
variable: '{{SAFETY_GUIDELINES}}'
diff --git a/src/app/config/app.config.ts b/src/app/config/app-config.ts
similarity index 81%
rename from src/app/config/app.config.ts
rename to src/app/config/app-config.ts
index 9e99b10..21e0a2b 100644
--- a/src/app/config/app.config.ts
+++ b/src/app/config/app-config.ts
@@ -10,7 +10,7 @@ export interface AppConfig {
VIEW_TEMPLATE_NAME: string;
README_TEMPLATE_NAME: string;
DEFAULT_CATEGORY: string;
- FORCE_REGENERATE: string;
+ FORCE_REGENERATE: boolean;
YAML_INDENT: number;
YAML_LINE_WIDTH: number;
}
@@ -22,10 +22,10 @@ export const appConfig: AppConfig = {
ANALYZER_PROMPT_PATH: path.join('src', 'system_prompts', 'prompt_analysis_agent', 'prompt.md'),
README_PATH: 'README.md',
VIEW_FILE_NAME: 'README.md',
- VIEW_TEMPLATE_NAME: 'sub_readme.md',
- README_TEMPLATE_NAME: 'main_readme.md',
+ VIEW_TEMPLATE_NAME: 'sub-readme.md',
+ README_TEMPLATE_NAME: 'main-readme.md',
DEFAULT_CATEGORY: 'uncategorized',
- FORCE_REGENERATE: process.env.FORCE_REGENERATE ?? 'false',
+ FORCE_REGENERATE: process.env.FORCE_REGENERATE === 'true',
YAML_INDENT: 2,
YAML_LINE_WIDTH: 80
};
diff --git a/src/app/controllers/__tests__/update-metadata.test.ts b/src/app/controllers/__tests__/update-metadata.test.ts
new file mode 100644
index 0000000..9a15127
--- /dev/null
+++ b/src/app/controllers/__tests__/update-metadata.test.ts
@@ -0,0 +1,272 @@
+import * as crypto from 'crypto';
+import * as path from 'path';
+
+import { jest } from '@jest/globals';
+
+import { commonConfig } from '../../../shared/config/common-config';
+import { PromptMetadata } from '../../../shared/types';
+import * as fileSystem from '../../../shared/utils/file-system';
+import logger from '../../../shared/utils/logger';
+import { appConfig } from '../../config/app-config';
+import * as promptAnalyzer from '../../utils/metadata-generator';
+import { generateMetadata, shouldUpdateMetadata, updateMetadataHash, updatePromptMetadata } from '../update-metadata';
+
+jest.mock('../../../shared/utils/file-system');
+jest.mock('../../utils/metadata-generator');
+jest.mock('../../../shared/utils/logger');
+jest.mock('fs-extra');
+
+describe('UpdateMetadataController', () => {
+ const mockPromptContent = 'Test prompt content';
+ const mockMetadata: PromptMetadata = {
+ title: 'Test Prompt',
+ primary_category: 'Testing',
+ directory: 'test-prompt',
+ one_line_description: 'A test prompt',
+ description: 'Test description',
+ subcategories: ['unit-test'],
+ tags: ['test'],
+ variables: []
+ };
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest.mocked(promptAnalyzer.processMetadataGeneration).mockResolvedValue(mockMetadata);
+ jest.mocked(fileSystem.readFileContent).mockResolvedValue(mockPromptContent);
+ jest.mocked(fileSystem.fileExists).mockResolvedValue(true);
+ jest.mocked(fileSystem.writeFileContent).mockResolvedValue();
+ jest.mocked(fileSystem.createDirectory).mockResolvedValue();
+ jest.mocked(fileSystem.isDirectory).mockResolvedValue(true);
+ jest.mocked(fileSystem.readDirectory).mockResolvedValue(['test-prompt']);
+ jest.mocked(fileSystem.renameFile).mockResolvedValue();
+ jest.mocked(fileSystem.removeDirectory).mockResolvedValue();
+ jest.mocked(fileSystem.isFile).mockResolvedValue(true);
+ jest.mocked(fileSystem.copyFile).mockResolvedValue();
+ });
+
+ describe('generateMetadata', () => {
+ it('should generate metadata from prompt content', async () => {
+ const result = await generateMetadata(mockPromptContent);
+ expect(result).toEqual(mockMetadata);
+ expect(promptAnalyzer.processMetadataGeneration).toHaveBeenCalledWith(mockPromptContent);
+ });
+
+ it('should handle errors during metadata generation', async () => {
+ const error = new Error('Generation failed');
+ jest.mocked(promptAnalyzer.processMetadataGeneration).mockRejectedValue(error);
+
+ await expect(generateMetadata(mockPromptContent)).rejects.toThrow(error);
+ expect(logger.error).toHaveBeenCalled();
+ });
+ });
+
+ describe('shouldUpdateMetadata', () => {
+ const promptFile = 'test.md';
+ const metadataFile = 'metadata.yml';
+ beforeEach(() => {
+ jest.mocked(fileSystem.readFileContent).mockResolvedValue(mockPromptContent);
+ jest.mocked(fileSystem.fileExists).mockResolvedValue(true);
+ appConfig.FORCE_REGENERATE = false;
+ });
+
+ it('should return true when force regenerate is enabled', async () => {
+ appConfig.FORCE_REGENERATE = true;
+ const [shouldUpdate, _promptHash] = await shouldUpdateMetadata(promptFile, metadataFile);
+ expect(shouldUpdate).toBe(true);
+ });
+
+ it('should return true when metadata file does not exist', async () => {
+ jest.mocked(fileSystem.fileExists).mockResolvedValue(false);
+ const [shouldUpdate, _promptHash] = await shouldUpdateMetadata(promptFile, metadataFile);
+ expect(shouldUpdate).toBe(true);
+ });
+
+ it('should return true when content hash is missing', async () => {
+ jest.mocked(fileSystem.readFileContent).mockResolvedValue('no hash here');
+ const [shouldUpdate, _promptHash] = await shouldUpdateMetadata(promptFile, metadataFile);
+ expect(shouldUpdate).toBe(true);
+ });
+
+ it('should return true when content hash differs', async () => {
+ const promptContent = 'prompt content';
+ const differentHash = 'different-hash-value';
+ jest.mocked(fileSystem.readFileContent)
+ .mockResolvedValueOnce(promptContent)
+ .mockResolvedValueOnce(`content_hash: ${differentHash}`);
+
+ const [shouldUpdate] = await shouldUpdateMetadata(promptFile, metadataFile);
+ expect(shouldUpdate).toBe(true);
+ });
+
+ it('should return false when content hash matches', async () => {
+ appConfig.FORCE_REGENERATE = false;
+
+ const promptContent = 'test content';
+ const computedHash = crypto.createHash('md5').update(promptContent).digest('hex');
+ const mockMetadataContent = `content_hash: ${computedHash}`;
+ jest.mocked(fileSystem.fileExists).mockResolvedValue(true);
+ jest.mocked(fileSystem.readFileContent)
+ .mockResolvedValueOnce(promptContent)
+ .mockResolvedValueOnce(mockMetadataContent);
+
+ const [shouldUpdate] = await shouldUpdateMetadata(promptFile, metadataFile);
+ expect(shouldUpdate).toBe(false);
+ });
+ });
+
+ describe('updateMetadataHash', () => {
+ const metadataFile = 'metadata.yml';
+ const newHash = 'newhash123';
+ it('should update existing hash in metadata file', async () => {
+ const mockMetadataContent = `content_hash: oldHash123`;
+ jest.mocked(fileSystem.readFileContent)
+ .mockResolvedValueOnce(mockPromptContent)
+ .mockResolvedValueOnce(mockMetadataContent);
+ await updateMetadataHash(metadataFile, newHash);
+
+ expect(fileSystem.writeFileContent).toHaveBeenCalledWith(
+ metadataFile,
+ expect.stringContaining(`content_hash: ${newHash}`)
+ );
+ });
+
+ it('should add hash if not present in metadata file', async () => {
+ const mockMetadataContent = `
+title: Test
+description: Test description
+other: content
+`;
+ jest.mocked(fileSystem.readFileContent).mockResolvedValue(mockMetadataContent);
+ await updateMetadataHash(metadataFile, newHash);
+
+ expect(fileSystem.writeFileContent).toHaveBeenCalledWith(
+ metadataFile,
+ expect.stringContaining(`content_hash: ${newHash}`)
+ );
+ });
+
+ it('should handle errors during hash update', async () => {
+ const error = new Error('Update failed');
+ jest.mocked(fileSystem.readFileContent).mockRejectedValue(error);
+ jest.mocked(fileSystem.writeFileContent).mockRejectedValue(error);
+
+ await expect(updateMetadataHash(metadataFile, newHash)).rejects.toThrow(error);
+ expect(logger.error).toHaveBeenCalled();
+ });
+ });
+
+ describe('updatePromptMetadata', () => {
+ it('should process main prompt file if it exists', async () => {
+ const mainPromptFile = path.join(appConfig.PROMPTS_DIR, commonConfig.PROMPT_FILE_NAME);
+ jest.mocked(fileSystem.fileExists).mockResolvedValueOnce(true);
+
+ await updatePromptMetadata();
+
+ expect(fileSystem.readFileContent).toHaveBeenCalledWith(mainPromptFile);
+ expect(fileSystem.createDirectory).toHaveBeenCalled();
+ expect(fileSystem.renameFile).toHaveBeenCalled();
+ expect(fileSystem.writeFileContent).toHaveBeenCalled();
+ });
+
+ it('should process prompt directories', async () => {
+ jest.mocked(fileSystem.fileExists).mockResolvedValueOnce(false).mockResolvedValueOnce(true);
+
+ await updatePromptMetadata();
+
+ expect(fileSystem.readDirectory).toHaveBeenCalledWith(appConfig.PROMPTS_DIR);
+ expect(fileSystem.isDirectory).toHaveBeenCalled();
+ expect(fileSystem.readFileContent).toHaveBeenCalled();
+ });
+
+ it('should handle errors during prompt processing', async () => {
+ const error = new Error('Processing failed');
+ jest.mocked(fileSystem.readDirectory).mockRejectedValue(error);
+
+ await expect(updatePromptMetadata()).rejects.toThrow('Processing failed');
+ expect(logger.error).toHaveBeenCalled();
+ });
+
+ it('should handle existing target directory during rename', async () => {
+ const oldDir = 'old-prompt';
+ const newDir = 'new-prompt';
+ const promptFile = commonConfig.PROMPT_FILE_NAME;
+ jest.mocked(fileSystem.fileExists).mockImplementation((filePath: string) => {
+ if (filePath.includes('prompt.md')) {
+ return Promise.resolve(true);
+ } else if (filePath.includes('metadata.yml')) {
+ return Promise.resolve(true);
+ } else if (filePath.includes(newDir)) {
+ return Promise.resolve(true);
+ } else {
+ return Promise.resolve(false);
+ }
+ });
+
+ jest.mocked(fileSystem.readDirectory).mockImplementation((dirPath: string) => {
+ if (dirPath === appConfig.PROMPTS_DIR) {
+ return Promise.resolve([oldDir]);
+ } else if (dirPath.includes(oldDir)) {
+ return Promise.resolve([promptFile]);
+ } else {
+ return Promise.resolve([]);
+ }
+ });
+
+ jest.mocked(promptAnalyzer.processMetadataGeneration).mockResolvedValue({
+ ...mockMetadata,
+ directory: newDir
+ });
+
+ await updatePromptMetadata();
+
+ expect(fileSystem.copyFile).toHaveBeenCalledWith(
+ expect.stringContaining(path.join(oldDir, promptFile)),
+ expect.stringContaining(path.join(newDir, promptFile))
+ );
+
+ expect(fileSystem.removeDirectory).toHaveBeenCalledWith(expect.stringContaining(oldDir));
+ });
+
+ it('should handle existing target directory during rename', async () => {
+ const oldDir = 'old-prompt';
+ const newDir = 'new-prompt';
+ const promptFile = commonConfig.PROMPT_FILE_NAME;
+ jest.mocked(fileSystem.fileExists).mockImplementation((filePath: string) => {
+ if (filePath.includes('prompt.md')) {
+ return Promise.resolve(true);
+ } else if (filePath.includes('metadata.yml')) {
+ return Promise.resolve(true);
+ } else if (filePath.includes(newDir)) {
+ return Promise.resolve(true);
+ } else {
+ return Promise.resolve(false);
+ }
+ });
+
+ jest.mocked(fileSystem.readDirectory).mockImplementation((dirPath: string) => {
+ if (dirPath === appConfig.PROMPTS_DIR) {
+ return Promise.resolve([oldDir]);
+ } else if (dirPath.includes(oldDir)) {
+ return Promise.resolve([promptFile]);
+ } else {
+ return Promise.resolve([]);
+ }
+ });
+
+ jest.mocked(promptAnalyzer.processMetadataGeneration).mockResolvedValue({
+ ...mockMetadata,
+ directory: newDir
+ });
+
+ jest.mocked(fileSystem.isFile).mockResolvedValue(true);
+ jest.mocked(fileSystem.isDirectory).mockResolvedValue(true);
+
+ await updatePromptMetadata();
+
+ expect(fileSystem.copyFile).toHaveBeenCalledWith(
+ expect.stringContaining(path.join(oldDir, promptFile)),
+ expect.stringContaining(path.join(newDir, promptFile))
+ );
+ expect(fileSystem.removeDirectory).toHaveBeenCalledWith(expect.stringContaining(oldDir));
+ });
+ });
+});
diff --git a/src/app/controllers/__tests__/update-views.test.ts b/src/app/controllers/__tests__/update-views.test.ts
new file mode 100644
index 0000000..2ad8e29
--- /dev/null
+++ b/src/app/controllers/__tests__/update-views.test.ts
@@ -0,0 +1,121 @@
+import * as path from 'path';
+
+import { jest } from '@jest/globals';
+import * as nunjucks from 'nunjucks';
+
+import { commonConfig } from '../../../shared/config/common-config';
+import { PromptMetadata } from '../../../shared/types';
+import * as fileSystem from '../../../shared/utils/file-system';
+import logger from '../../../shared/utils/logger';
+import { appConfig } from '../../config/app-config';
+import { updateViews } from '../update-views';
+
+jest.mock('nunjucks');
+jest.mock('../../../shared/utils/file-system');
+jest.mock('../../../shared/utils/logger');
+
+describe('UpdateViewsController', () => {
+ const mockPromptDir = 'test-prompt';
+ const mockPromptPath = path.join(appConfig.PROMPTS_DIR, mockPromptDir);
+ const mockMetadata: PromptMetadata = {
+ title: 'Test Prompt',
+ primary_category: 'Testing',
+ one_line_description: 'A test prompt',
+ subcategories: ['unit-test'],
+ description: '',
+ directory: '',
+ tags: [],
+ variables: []
+ };
+ const mockPromptContent = 'Test prompt content';
+ const mockViewContent = 'Generated view content';
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest.mocked(fileSystem.isDirectory).mockResolvedValue(true);
+ jest.mocked(fileSystem.readDirectory).mockResolvedValue([mockPromptDir]);
+ jest.mocked(fileSystem.readFileContent).mockImplementation(async (filePath: string) => {
+ if (filePath.endsWith(commonConfig.PROMPT_FILE_NAME)) {
+ return mockPromptContent;
+ }
+
+ if (filePath.endsWith(commonConfig.METADATA_FILE_NAME)) {
+ return JSON.stringify(mockMetadata);
+ }
+ return '';
+ });
+ jest.mocked(fileSystem.writeFileContent).mockResolvedValue();
+ jest.mocked(nunjucks.render).mockImplementation(() => mockViewContent);
+ jest.mocked(nunjucks.configure).mockReturnValue(new nunjucks.Environment());
+ });
+
+ it('should process prompt directories and generate views', async () => {
+ await updateViews();
+ expect(nunjucks.configure).toHaveBeenCalledWith(appConfig.TEMPLATES_DIR, { autoescape: false });
+ expect(fileSystem.readDirectory).toHaveBeenCalledWith(appConfig.PROMPTS_DIR);
+
+ expect(fileSystem.readFileContent).toHaveBeenCalledWith(
+ path.join(mockPromptPath, commonConfig.PROMPT_FILE_NAME)
+ );
+ expect(fileSystem.readFileContent).toHaveBeenCalledWith(
+ path.join(mockPromptPath, commonConfig.METADATA_FILE_NAME)
+ );
+ expect(nunjucks.render).toHaveBeenCalledWith(
+ appConfig.VIEW_TEMPLATE_NAME,
+ expect.objectContaining({
+ metadata: mockMetadata,
+ prompt_content: mockPromptContent
+ })
+ );
+ expect(fileSystem.writeFileContent).toHaveBeenCalledWith(
+ path.join(mockPromptPath, appConfig.VIEW_FILE_NAME),
+ mockViewContent
+ );
+ expect(nunjucks.render).toHaveBeenCalledWith(
+ appConfig.README_TEMPLATE_NAME,
+ expect.objectContaining({
+ categories: expect.any(Object)
+ })
+ );
+ });
+
+ it('should handle errors gracefully', async () => {
+ const mockError = new Error('Test error');
+ jest.mocked(fileSystem.readDirectory).mockRejectedValue(mockError);
+
+ await expect(updateViews()).rejects.toThrow(mockError);
+ expect(logger.error).toHaveBeenCalled();
+ });
+
+ it('should skip non-directory entries', async () => {
+ jest.mocked(fileSystem.isDirectory).mockResolvedValue(false);
+
+ await updateViews();
+
+ expect(fileSystem.readFileContent).not.toHaveBeenCalled();
+ expect(nunjucks.render).toHaveBeenCalledTimes(1);
+ });
+
+ it('should use default category when primary_category is missing', async () => {
+ const metadataWithoutCategory: PromptMetadata = {
+ ...mockMetadata,
+ primary_category: appConfig.DEFAULT_CATEGORY
+ };
+ jest.mocked(fileSystem.readFileContent).mockImplementation(async (filePath: string) => {
+ if (filePath.endsWith(commonConfig.METADATA_FILE_NAME)) {
+ return JSON.stringify(metadataWithoutCategory);
+ }
+ return mockPromptContent;
+ });
+
+ await updateViews();
+
+ expect(nunjucks.render).toHaveBeenCalledWith(
+ appConfig.README_TEMPLATE_NAME,
+ expect.objectContaining({
+ categories: expect.objectContaining({
+ [appConfig.DEFAULT_CATEGORY]: expect.any(Array)
+ })
+ })
+ );
+ });
+});
diff --git a/src/app/core/update_metadata.ts b/src/app/controllers/update-metadata.ts
similarity index 88%
rename from src/app/core/update_metadata.ts
rename to src/app/controllers/update-metadata.ts
index e19dc3b..286b6b1 100644
--- a/src/app/core/update_metadata.ts
+++ b/src/app/controllers/update-metadata.ts
@@ -1,8 +1,8 @@
import * as crypto from 'crypto';
import * as path from 'path';
-import { commonConfig } from '../../shared/config/common.config';
-import { Metadata } from '../../shared/types';
+import { commonConfig } from '../../shared/config/common-config';
+import { PromptMetadata } from '../../shared/types';
import {
copyFile,
createDirectory,
@@ -14,13 +14,13 @@ import {
removeDirectory,
renameFile,
writeFileContent
-} from '../../shared/utils/file_system.util';
-import logger from '../../shared/utils/logger.util';
-import { appConfig } from '../config/app.config';
-import { processMetadataGeneration } from '../utils/prompt_analyzer.util';
-import { dumpYamlContent, sanitizeYamlContent } from '../utils/yaml_operations.util';
+} from '../../shared/utils/file-system';
+import logger from '../../shared/utils/logger';
+import { appConfig } from '../config/app-config';
+import { processMetadataGeneration } from '../utils/metadata-generator';
+import { dumpYamlContent, sanitizeYamlContent } from '../utils/yaml-operations';
-export async function generateMetadata(promptContent: string): Promise {
+export async function generateMetadata(promptContent: string): Promise {
logger.info('Starting metadata generation');
try {
@@ -32,7 +32,7 @@ export async function generateMetadata(promptContent: string): Promise
}
export async function shouldUpdateMetadata(promptFile: string, metadataFile: string): Promise<[boolean, string]> {
- const forceRegenerate = appConfig.FORCE_REGENERATE === 'true';
+ const forceRegenerate = appConfig.FORCE_REGENERATE;
const promptContent = await readFileContent(promptFile);
const promptHash = crypto.createHash('md5').update(promptContent).digest('hex');
@@ -56,8 +56,9 @@ export async function shouldUpdateMetadata(promptFile: string, metadataFile: str
}
const storedHash = storedHashLine.split(':')[1].trim();
+ const hashesMatch = promptHash === storedHash;
- if (promptHash !== storedHash) {
+ if (!hashesMatch) {
logger.info(`Content hash mismatch for ${promptFile}. Update needed.`);
return [true, promptHash];
}
@@ -91,12 +92,12 @@ export async function updateMetadataHash(metadataFile: string, newHash: string):
}
export async function updatePromptMetadata(): Promise {
- logger.info('Starting update_prompt_metadata process');
+ logger.info('Starting update-metadata process');
try {
await processMainPrompt(appConfig.PROMPTS_DIR);
await processPromptDirectories(appConfig.PROMPTS_DIR);
- logger.info('update_prompt_metadata process completed');
+ logger.info('update-metadata process completed');
} catch (error) {
logger.error('Error in updatePromptMetadata:', error);
throw error;
@@ -201,16 +202,16 @@ async function updatePromptDirectory(
if (await fileExists(newDirPath)) {
logger.warn(`Directory ${newDirName} already exists. Updating contents.`);
const files = await readDirectory(currentItemPath);
- await Promise.all(
- files.map(async (file) => {
- const src = path.join(currentItemPath, file);
- const dst = path.join(newDirPath, file);
-
- if (await isFile(src)) {
- await copyFile(src, dst);
- }
- })
- );
+
+ for (const file of files) {
+ const src = path.join(currentItemPath, file);
+ const dst = path.join(newDirPath, file);
+
+ if (await isFile(src)) {
+ await copyFile(src, dst);
+ }
+ }
+
await removeDirectory(currentItemPath);
} else {
await renameFile(currentItemPath, newDirPath);
diff --git a/src/app/core/update_views.ts b/src/app/controllers/update-views.ts
similarity index 82%
rename from src/app/core/update_views.ts
rename to src/app/controllers/update-views.ts
index c17de1b..0c28465 100644
--- a/src/app/core/update_views.ts
+++ b/src/app/controllers/update-views.ts
@@ -2,13 +2,13 @@ import * as path from 'path';
import * as nunjucks from 'nunjucks';
-import { commonConfig } from '../../shared/config/common.config';
-import { CategoryItem, Metadata } from '../../shared/types';
-import { isDirectory, readDirectory, readFileContent, writeFileContent } from '../../shared/utils/file_system.util';
-import logger from '../../shared/utils/logger.util';
-import { formatTitleCase } from '../../shared/utils/string_formatter.util';
-import { appConfig } from '../config/app.config';
-import { parseYamlContent } from '../utils/yaml_operations.util';
+import { commonConfig } from '../../shared/config/common-config';
+import { CategoryItem, PromptMetadata } from '../../shared/types';
+import { isDirectory, readDirectory, readFileContent, writeFileContent } from '../../shared/utils/file-system';
+import logger from '../../shared/utils/logger';
+import { formatTitleCase } from '../../shared/utils/string-formatter';
+import { appConfig } from '../config/app-config';
+import { parseYamlContent } from '../utils/yaml-operations';
async function processPromptDirectory(promptDir: string, categories: Record): Promise {
const promptPath = path.join(appConfig.PROMPTS_DIR, promptDir);
@@ -26,7 +26,7 @@ async function processPromptDirectory(promptDir: string, categories: Record {
+async function generateViewFile(promptPath: string, metadata: PromptMetadata, promptContent: string): Promise {
try {
const viewContent = nunjucks.render(appConfig.VIEW_TEMPLATE_NAME, {
metadata,
@@ -57,7 +57,7 @@ async function generateViewFile(promptPath: string, metadata: Metadata, promptCo
function addPromptToCategories(
categories: Record,
promptDir: string,
- metadata: Metadata
+ metadata: PromptMetadata
): void {
const primaryCategory = metadata.primary_category || appConfig.DEFAULT_CATEGORY;
categories[primaryCategory] = categories[primaryCategory] || [];
@@ -73,7 +73,7 @@ function addPromptToCategories(
}
export async function updateViews(): Promise {
- logger.info('Starting update_views process');
+ logger.info('Starting update-views process');
const categories: Record = {};
try {
@@ -84,9 +84,8 @@ export async function updateViews(): Promise {
logger.info(`Iterating through prompts in ${appConfig.PROMPTS_DIR}`);
const promptDirs = await readDirectory(appConfig.PROMPTS_DIR);
await Promise.all(promptDirs.map((promptDir) => processPromptDirectory(promptDir, categories)));
-
await generateReadme(categories);
- logger.info('update_views process completed');
+ logger.info('update-views process completed');
} catch (error) {
logger.error('Error in updateViews:', error);
throw error;
@@ -99,6 +98,7 @@ async function generateReadme(categories: Record): Promi
Object.entries(categories)
.filter(([, v]) => v.length > 0)
.sort(([a], [b]) => a.localeCompare(b))
+ .map(([category, items]) => [category, items.sort((a, b) => a.title.localeCompare(b.title))])
);
logger.info('Generating README content');
const readmeContent = nunjucks.render(appConfig.README_TEMPLATE_NAME, {
diff --git a/src/app/templates/main_readme.md b/src/app/templates/main-readme.md
similarity index 100%
rename from src/app/templates/main_readme.md
rename to src/app/templates/main-readme.md
diff --git a/src/app/templates/sub_readme.md b/src/app/templates/sub-readme.md
similarity index 100%
rename from src/app/templates/sub_readme.md
rename to src/app/templates/sub-readme.md
diff --git a/src/app/utils/__tests__/analyze-prompt.test.ts b/src/app/utils/__tests__/analyze-prompt.test.ts
new file mode 100644
index 0000000..c6b9d3b
--- /dev/null
+++ b/src/app/utils/__tests__/analyze-prompt.test.ts
@@ -0,0 +1,61 @@
+import logger from '../../../shared/utils/logger';
+import { analyzePrompt } from '../analyze-prompt';
+import { processMetadataGeneration } from '../metadata-generator';
+
+jest.mock('../metadata-generator', () => ({
+ processMetadataGeneration: jest.fn()
+}));
+jest.mock('../../../shared/utils/logger', () => ({
+ info: jest.fn(),
+ error: jest.fn(),
+ warn: jest.fn()
+}));
+
+describe('AnalyzePromptUtils', () => {
+ const mockPromptContent = 'Test prompt content';
+ const mockMetadata = {
+ title: 'Test Title',
+ description: 'Detailed description',
+ primary_category: 'Test Category',
+ subcategories: ['sub1', 'sub2'],
+ directory: 'test-dir',
+ tags: ['tag1', 'tag2'],
+ one_line_description: 'Test description',
+ variables: [
+ {
+ name: 'var1',
+ type: 'string',
+ role: 'system',
+ optional_for_user: false
+ }
+ ],
+ content_hash: 'hash123',
+ fragments: [
+ {
+ name: 'fragment1',
+ category: 'test',
+ variable: 'TEST_VAR'
+ }
+ ]
+ };
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should analyze prompt successfully', async () => {
+ (processMetadataGeneration as jest.Mock).mockResolvedValueOnce(mockMetadata);
+
+ const result = await analyzePrompt(mockPromptContent);
+ expect(result).toEqual(mockMetadata);
+ expect(logger.info).toHaveBeenCalledWith('Starting prompt analysis');
+ expect(logger.info).toHaveBeenCalledWith('Prompt analysis completed successfully');
+ });
+
+ it('should handle errors during analysis', async () => {
+ const error = new Error('Analysis failed');
+ (processMetadataGeneration as jest.Mock).mockRejectedValueOnce(error);
+
+ await expect(analyzePrompt(mockPromptContent)).rejects.toThrow('Analysis failed');
+ expect(logger.error).toHaveBeenCalledWith('Error analyzing prompt:', error);
+ });
+});
diff --git a/src/app/utils/__tests__/fragment-manager.test.ts b/src/app/utils/__tests__/fragment-manager.test.ts
new file mode 100644
index 0000000..e117503
--- /dev/null
+++ b/src/app/utils/__tests__/fragment-manager.test.ts
@@ -0,0 +1,77 @@
+import { readDirectory, isDirectory } from '../../../shared/utils/file-system';
+import logger from '../../../shared/utils/logger';
+import { listAvailableFragments } from '../fragment-manager';
+
+jest.mock('../../../shared/utils/file-system');
+jest.mock('../../../shared/utils/logger');
+jest.mock('../../config/app-config', () => ({
+ appConfig: {
+ FRAGMENTS_DIR: '/mock/fragments/dir'
+ }
+}));
+
+describe('FragmentManagerUtils', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should list fragments from all categories', async () => {
+ const mockReadDirectory = readDirectory as jest.MockedFunction;
+ const mockIsDirectory = isDirectory as jest.MockedFunction;
+ mockReadDirectory.mockImplementationOnce(async () => ['category1', 'category2']);
+
+ mockReadDirectory
+ .mockImplementationOnce(async () => ['fragment1.js', 'fragment2.js'])
+ .mockImplementationOnce(async () => ['fragment3.js']);
+
+ mockIsDirectory.mockImplementation(async () => true);
+
+ const result = await listAvailableFragments();
+ const parsed = JSON.parse(result);
+ expect(parsed).toEqual({
+ category1: ['fragment1', 'fragment2'],
+ category2: ['fragment3']
+ });
+
+ expect(logger.info).toHaveBeenCalledWith('Listing available fragments');
+ expect(logger.info).toHaveBeenCalledWith('Listed fragments from 2 categories');
+ });
+
+ it('should skip non-directory entries', async () => {
+ const mockReadDirectory = readDirectory as jest.MockedFunction;
+ const mockIsDirectory = isDirectory as jest.MockedFunction;
+ mockReadDirectory.mockImplementationOnce(async () => ['category1', 'not-a-dir']);
+ mockReadDirectory.mockImplementationOnce(async () => ['fragment1.js']);
+
+ mockIsDirectory.mockImplementationOnce(async () => true).mockImplementationOnce(async () => false);
+
+ const result = await listAvailableFragments();
+ const parsed = JSON.parse(result);
+ expect(parsed).toEqual({
+ category1: ['fragment1']
+ });
+ });
+
+ it('should handle empty categories', async () => {
+ const mockReadDirectory = readDirectory as jest.MockedFunction;
+ const mockIsDirectory = isDirectory as jest.MockedFunction;
+ mockReadDirectory.mockImplementationOnce(async () => ['empty-category']);
+ mockReadDirectory.mockImplementationOnce(async () => []);
+ mockIsDirectory.mockImplementation(async () => true);
+
+ const result = await listAvailableFragments();
+ const parsed = JSON.parse(result);
+ expect(parsed).toEqual({
+ 'empty-category': []
+ });
+ });
+
+ it('should handle errors and log them', async () => {
+ const mockReadDirectory = readDirectory as jest.MockedFunction;
+ const error = new Error('Test error');
+ mockReadDirectory.mockRejectedValue(error);
+
+ await expect(listAvailableFragments()).rejects.toThrow('Test error');
+ expect(logger.error).toHaveBeenCalledWith('Error listing available fragments:', error);
+ });
+});
diff --git a/src/app/utils/__tests__/metadata-generator.test.ts b/src/app/utils/__tests__/metadata-generator.test.ts
new file mode 100644
index 0000000..f997ec0
--- /dev/null
+++ b/src/app/utils/__tests__/metadata-generator.test.ts
@@ -0,0 +1,114 @@
+import { readFileContent } from '../../../shared/utils/file-system';
+import logger from '../../../shared/utils/logger';
+import { processPromptContent } from '../../../shared/utils/prompt-processing';
+import { appConfig } from '../../config/app-config';
+import { listAvailableFragments } from '../fragment-manager';
+import { loadAnalyzerPrompt, processMetadataGeneration } from '../metadata-generator';
+import { parseYamlContent } from '../yaml-operations';
+
+jest.mock('../fragment-manager');
+jest.mock('../yaml-operations');
+jest.mock('../../../shared/utils/file-system');
+jest.mock('../../../shared/utils/prompt-processing', () => ({
+ processPromptContent: jest.fn(),
+ updatePromptWithVariables: jest.fn()
+}));
+jest.mock('../../config/app-config', () => ({
+ appConfig: {
+ ANALYZER_PROMPT_PATH: '/mock/analyzer/prompt.txt'
+ }
+}));
+jest.mock('../analyze-prompt', () => ({
+ analyzePrompt: jest.fn()
+}));
+jest.mock('../../../shared/utils/logger', () => ({
+ info: jest.fn(),
+ error: jest.fn(),
+ warn: jest.fn()
+}));
+
+describe('MetadataGeneratorUtils', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('loadAnalyzerPrompt', () => {
+ it('should load analyzer prompt successfully', async () => {
+ const mockContent = 'Mock analyzer prompt content';
+ (readFileContent as jest.Mock).mockResolvedValue(mockContent);
+
+ const result = await loadAnalyzerPrompt();
+ expect(result).toBe(mockContent);
+ expect(readFileContent).toHaveBeenCalledWith(appConfig.ANALYZER_PROMPT_PATH);
+ expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Loading analyzer prompt'));
+ });
+
+ it('should handle errors when loading analyzer prompt', async () => {
+ const error = new Error('Failed to read file');
+ (readFileContent as jest.Mock).mockRejectedValue(error);
+
+ await expect(loadAnalyzerPrompt()).rejects.toThrow('Failed to read file');
+ expect(logger.error).toHaveBeenCalledWith('Error loading analyzer prompt:', error);
+ });
+ });
+
+ describe('processMetadataGeneration', () => {
+ const mockPromptContent = 'Test prompt content';
+ const mockAnalyzerPrompt = 'Analyzer prompt';
+ const mockFragments = '{"category": ["fragment1"]}';
+ const mockProcessedContent = '';
+ const mockParsedMetadata = {
+ title: 'Test Title',
+ primary_category: 'Test Category',
+ subcategories: ['sub1', 'sub2'],
+ directory: 'test-dir',
+ tags: ['tag1', 'tag2'],
+ one_line_description: 'Test description',
+ description: 'Detailed description',
+ variables: [
+ {
+ name: 'var1',
+ type: 'string',
+ role: 'system',
+ optional_for_user: false
+ }
+ ],
+ content_hash: 'hash123',
+ fragments: [
+ {
+ name: 'fragment1',
+ category: 'test',
+ variable: 'TEST_VAR'
+ }
+ ]
+ };
+ beforeEach(() => {
+ (readFileContent as jest.Mock).mockResolvedValue(mockAnalyzerPrompt);
+ (listAvailableFragments as jest.Mock).mockResolvedValue(mockFragments);
+ (processPromptContent as jest.Mock).mockResolvedValue(mockProcessedContent);
+ (parseYamlContent as jest.Mock).mockReturnValue(mockParsedMetadata);
+ });
+
+ it('should generate metadata successfully', async () => {
+ const result = await processMetadataGeneration(mockPromptContent);
+ expect(result).toEqual(mockParsedMetadata);
+ expect(listAvailableFragments).toHaveBeenCalled();
+ expect(processPromptContent).toHaveBeenCalled();
+ expect(parseYamlContent).toHaveBeenCalled();
+ });
+
+ it('should throw error for invalid metadata', async () => {
+ const invalidMetadata = { ...mockParsedMetadata, title: '' };
+ (parseYamlContent as jest.Mock).mockReturnValue(invalidMetadata);
+
+ await expect(processMetadataGeneration(mockPromptContent)).rejects.toThrow('Invalid metadata generated');
+ });
+
+ it('should handle missing output tags', async () => {
+ (processPromptContent as jest.Mock).mockResolvedValue('content without tags');
+ await processMetadataGeneration(mockPromptContent);
+
+ expect(logger.warn).toHaveBeenCalledWith('Output tags not found in content, returning trimmed content');
+ });
+ });
+});
diff --git a/src/app/utils/__tests__/prompt-analyzer-cli.test.ts b/src/app/utils/__tests__/prompt-analyzer-cli.test.ts
new file mode 100644
index 0000000..b0767c1
--- /dev/null
+++ b/src/app/utils/__tests__/prompt-analyzer-cli.test.ts
@@ -0,0 +1,83 @@
+import { readFileContent } from '../../../shared/utils/file-system';
+import { analyzePrompt } from '../analyze-prompt';
+import { runPromptAnalyzerFromCLI } from '../prompt-analyzer-cli';
+
+jest.mock('../../../shared/utils/file-system');
+jest.mock('../analyze-prompt', () => ({
+ analyzePrompt: jest.fn()
+}));
+
+describe('PromptAnalyzerCLIUtils', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('runPromptAnalyzerFromCLI', () => {
+ let mockExit: jest.SpyInstance;
+ beforeEach(() => {
+ jest.spyOn(console, 'error').mockImplementation(() => {});
+ jest.spyOn(console, 'log').mockImplementation(() => {});
+ mockExit = jest
+ .spyOn(process, 'exit')
+ .mockImplementation((_code?: number | string | null | undefined): never => undefined as never);
+ });
+
+ afterEach(() => {
+ (console.error as jest.Mock).mockRestore();
+ (console.log as jest.Mock).mockRestore();
+ mockExit.mockRestore();
+ });
+
+ it('should read prompt file and analyze prompt', async () => {
+ const mockPromptPath = '/path/to/prompt.txt';
+ const mockPromptContent = 'Test prompt content';
+ const mockMetadata = {
+ title: 'Test Title',
+ description: 'Detailed description',
+ primary_category: 'Test Category',
+ subcategories: ['sub1', 'sub2'],
+ directory: 'test-dir',
+ tags: ['tag1', 'tag2'],
+ one_line_description: 'Test description',
+ variables: [
+ {
+ name: 'var1',
+ type: 'string',
+ role: 'system',
+ optional_for_user: false
+ }
+ ],
+ content_hash: 'hash123',
+ fragments: [
+ {
+ name: 'fragment1',
+ category: 'test',
+ variable: 'TEST_VAR'
+ }
+ ]
+ };
+ (readFileContent as jest.Mock).mockResolvedValueOnce(mockPromptContent);
+ (analyzePrompt as jest.Mock).mockResolvedValueOnce(mockMetadata);
+
+ await runPromptAnalyzerFromCLI([mockPromptPath]);
+
+ expect(readFileContent).toHaveBeenCalledWith(mockPromptPath);
+ expect(analyzePrompt).toHaveBeenCalledWith(mockPromptContent);
+ expect(console.log).toHaveBeenCalledWith('Generated Metadata:');
+ expect(console.log).toHaveBeenCalledWith(JSON.stringify(mockMetadata, null, 2));
+
+ expect(mockExit).toHaveBeenCalledWith(0);
+ });
+
+ it('should handle errors during processing', async () => {
+ const mockPromptPath = '/path/to/prompt.txt';
+ const error = new Error('Test error');
+ (readFileContent as jest.Mock).mockRejectedValue(error);
+
+ await runPromptAnalyzerFromCLI([mockPromptPath]);
+
+ expect(console.error).toHaveBeenCalledWith('Error:', error);
+ expect(mockExit).toHaveBeenCalledWith(1);
+ });
+ });
+});
diff --git a/src/app/utils/__tests__/yaml-operations.test.ts b/src/app/utils/__tests__/yaml-operations.test.ts
new file mode 100644
index 0000000..27bb2a4
--- /dev/null
+++ b/src/app/utils/__tests__/yaml-operations.test.ts
@@ -0,0 +1,203 @@
+import * as yaml from 'js-yaml';
+
+import { PromptMetadata } from '../../../shared/types';
+import logger from '../../../shared/utils/logger';
+import { appConfig } from '../../config/app-config';
+import {
+ parseYamlContent,
+ dumpYamlContent,
+ isValidMetadata,
+ parseAndValidateYamlContent,
+ sanitizeYamlContent,
+ needsSanitization
+} from '../yaml-operations';
+
+jest.mock('../../../shared/utils/logger', () => ({
+ debug: jest.fn(),
+ error: jest.fn(),
+ warn: jest.fn()
+}));
+jest.mock('js-yaml', () => {
+ const originalModule = jest.requireActual('js-yaml');
+ return {
+ ...originalModule,
+ dump: jest.fn(originalModule.dump)
+ };
+});
+
+describe('YAMLOperationsUtils', () => {
+ const mockValidMetadata: PromptMetadata = {
+ title: 'Test Title',
+ primary_category: 'Test Category',
+ subcategories: ['sub1', 'sub2'],
+ directory: 'test-dir',
+ tags: ['tag1', 'tag2'],
+ one_line_description: 'Test description',
+ description: 'Detailed description',
+ variables: [
+ {
+ name: 'var1',
+ role: 'system',
+ optional_for_user: false
+ }
+ ]
+ };
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('parseYamlContent', () => {
+ it('should parse valid YAML content', () => {
+ const yamlString = yaml.dump(mockValidMetadata);
+ const result = parseYamlContent(yamlString);
+ expect(result).toEqual(mockValidMetadata);
+ });
+
+ it('should handle XML-like wrapped content', () => {
+ const wrappedContent = `\n${yaml.dump(mockValidMetadata)}`;
+ const result = parseYamlContent(wrappedContent);
+ expect(result).toEqual(mockValidMetadata);
+ });
+
+ it('should throw error for invalid YAML', () => {
+ const invalidYaml = '{\n invalid: yaml: content:';
+ expect(() => parseYamlContent(invalidYaml)).toThrow();
+ });
+ });
+
+ describe('dumpYamlContent', () => {
+ it('should dump metadata to YAML format', () => {
+ const result = dumpYamlContent(mockValidMetadata);
+ const parsed = yaml.load(result) as PromptMetadata;
+ expect(parsed).toEqual(mockValidMetadata);
+ });
+
+ it('should use configured indent and line width', () => {
+ const result = dumpYamlContent(mockValidMetadata);
+ const lines = result.split('\n');
+ const indentMatch = lines.some((line) => line.startsWith(' '.repeat(appConfig.YAML_INDENT)));
+ expect(indentMatch).toBeTruthy();
+ });
+
+ it('should throw error for invalid input', () => {
+ const invalidInput = {
+ circular: {}
+ };
+ invalidInput.circular = invalidInput;
+ expect(() => dumpYamlContent(invalidInput as any)).toThrow();
+ });
+ });
+
+ describe('isValidMetadata', () => {
+ it('should validate correct metadata', () => {
+ expect(isValidMetadata(mockValidMetadata)).toBeTruthy();
+ });
+
+ it('should reject null input', () => {
+ expect(isValidMetadata(null)).toBeFalsy();
+ });
+
+ it('should reject missing required fields', () => {
+ const invalidMetadata = { ...mockValidMetadata };
+ delete (invalidMetadata as any).title;
+ expect(isValidMetadata(invalidMetadata)).toBeFalsy();
+ });
+
+ it('should reject invalid variable structure', () => {
+ const invalidMetadata = {
+ ...mockValidMetadata,
+ variables: [{ invalid: 'structure' }]
+ };
+ expect(isValidMetadata(invalidMetadata)).toBeFalsy();
+ });
+
+ it('should reject variables that are not objects', () => {
+ const invalidMetadata = {
+ ...mockValidMetadata,
+ variables: [null]
+ };
+ expect(isValidMetadata(invalidMetadata)).toBeFalsy();
+ });
+ });
+
+ describe('parseAndValidateYamlContent', () => {
+ it('should parse and validate correct YAML', () => {
+ const yamlString = yaml.dump(mockValidMetadata);
+ const result = parseAndValidateYamlContent(yamlString);
+ expect(result).toEqual(mockValidMetadata);
+ });
+
+ it('should throw error for invalid metadata structure', () => {
+ const invalidYaml = yaml.dump({ invalid: 'structure' });
+ expect(() => parseAndValidateYamlContent(invalidYaml)).toThrow();
+ });
+ });
+
+ describe('sanitizeYamlContent', () => {
+ it('should handle simple key-value content', () => {
+ const content = 'content_hash: abc123\n';
+ const result = sanitizeYamlContent(content);
+ expect(result).toBe(content);
+ });
+
+ it('should sanitize complex YAML content', () => {
+ const messyYaml = yaml.dump(mockValidMetadata).replace(/\n/g, '\n\n');
+ const result = sanitizeYamlContent(messyYaml);
+ expect(result).toBe(
+ yaml
+ .dump(mockValidMetadata, {
+ indent: appConfig.YAML_INDENT,
+ lineWidth: appConfig.YAML_LINE_WIDTH,
+ noRefs: true,
+ sortKeys: true
+ })
+ .trim() + '\n'
+ );
+ });
+
+ it('should handle JSON content', () => {
+ const jsonContent = JSON.stringify(mockValidMetadata);
+ const result = sanitizeYamlContent(jsonContent);
+ expect(() => yaml.load(result)).not.toThrow();
+ });
+
+ it('should return content as-is if both JSON and YAML parsing fail', () => {
+ const invalidContent = 'not valid yaml or json';
+ const result = sanitizeYamlContent(invalidContent);
+ expect(result).toBe(invalidContent.trim() + '\n');
+ });
+
+ it('should throw error when dumping fails in sanitizeYamlContent', () => {
+ const content = 'valid: yaml';
+ (yaml.dump as jest.Mock).mockImplementationOnce(() => {
+ throw new Error('Dumping failed');
+ });
+
+ expect(() => sanitizeYamlContent(content)).toThrow('Dumping failed');
+ });
+ });
+
+ describe('needsSanitization', () => {
+ it('should detect content needing sanitization', () => {
+ const messyYaml = yaml.dump(mockValidMetadata).replace(/\n/g, '\n\n');
+ expect(needsSanitization(messyYaml)).toBeTruthy();
+ });
+
+ it('should pass already sanitized content', () => {
+ const cleanYaml = yaml.dump(mockValidMetadata, {
+ indent: appConfig.YAML_INDENT,
+ lineWidth: appConfig.YAML_LINE_WIDTH,
+ noRefs: true,
+ sortKeys: true
+ });
+ expect(needsSanitization(cleanYaml)).toBeFalsy();
+ });
+
+ it('should return true for invalid YAML content needing sanitization', () => {
+ const invalidYaml = 'invalid: yaml: content';
+ const result = needsSanitization(invalidYaml);
+ expect(result).toBeTruthy();
+ expect(logger.error).toHaveBeenCalledWith('Error checking YAML sanitization:', expect.any(Error));
+ });
+ });
+});
diff --git a/src/app/utils/analyze-prompt.ts b/src/app/utils/analyze-prompt.ts
new file mode 100644
index 0000000..2f23d43
--- /dev/null
+++ b/src/app/utils/analyze-prompt.ts
@@ -0,0 +1,15 @@
+import { processMetadataGeneration } from './metadata-generator';
+import { PromptMetadata } from '../../shared/types';
+import logger from '../../shared/utils/logger';
+
+export async function analyzePrompt(promptContent: string): Promise {
+ try {
+ logger.info('Starting prompt analysis');
+ const metadata = await processMetadataGeneration(promptContent);
+ logger.info('Prompt analysis completed successfully');
+ return metadata;
+ } catch (error) {
+ logger.error('Error analyzing prompt:', error);
+ throw error;
+ }
+}
diff --git a/src/app/utils/fragment_manager.util.ts b/src/app/utils/fragment-manager.ts
similarity index 90%
rename from src/app/utils/fragment_manager.util.ts
rename to src/app/utils/fragment-manager.ts
index fbda61a..58dd2ba 100644
--- a/src/app/utils/fragment_manager.util.ts
+++ b/src/app/utils/fragment-manager.ts
@@ -1,8 +1,8 @@
import path from 'path';
-import { isDirectory, readDirectory } from '../../shared/utils/file_system.util';
-import logger from '../../shared/utils/logger.util';
-import { appConfig } from '../config/app.config';
+import { isDirectory, readDirectory } from '../../shared/utils/file-system';
+import logger from '../../shared/utils/logger';
+import { appConfig } from '../config/app-config';
export async function listAvailableFragments(): Promise {
try {
diff --git a/src/app/utils/prompt_analyzer.util.ts b/src/app/utils/metadata-generator.ts
similarity index 66%
rename from src/app/utils/prompt_analyzer.util.ts
rename to src/app/utils/metadata-generator.ts
index 4fda4e5..aa98a76 100644
--- a/src/app/utils/prompt_analyzer.util.ts
+++ b/src/app/utils/metadata-generator.ts
@@ -1,10 +1,10 @@
-import { listAvailableFragments } from './fragment_manager.util';
-import { parseYamlContent } from './yaml_operations.util';
-import { Metadata } from '../../shared/types';
-import { readFileContent } from '../../shared/utils/file_system.util';
-import logger from '../../shared/utils/logger.util';
-import { processPromptContent, updatePromptWithVariables } from '../../shared/utils/prompt_processing.util';
-import { appConfig } from '../config/app.config';
+import { listAvailableFragments } from './fragment-manager';
+import { parseYamlContent } from './yaml-operations';
+import { PromptMetadata } from '../../shared/types';
+import { readFileContent } from '../../shared/utils/file-system';
+import logger from '../../shared/utils/logger';
+import { processPromptContent, updatePromptWithVariables } from '../../shared/utils/prompt-processing';
+import { appConfig } from '../config/app-config';
export async function loadAnalyzerPrompt(): Promise {
try {
@@ -18,7 +18,7 @@ export async function loadAnalyzerPrompt(): Promise {
}
}
-export async function processMetadataGeneration(promptContent: string): Promise {
+export async function processMetadataGeneration(promptContent: string): Promise {
logger.info('Processing prompt for metadata generation');
try {
@@ -34,7 +34,7 @@ export async function processMetadataGeneration(promptContent: string): Promise<
const content = await processPromptContent([{ role: 'user', content: updatedPromptContent }], false);
const yamlContent = extractOutputContent(content);
const parsedMetadata = parseYamlContent(yamlContent);
- const metadata: Metadata = {
+ const metadata: PromptMetadata = {
title: parsedMetadata.title || '',
primary_category: parsedMetadata.primary_category || '',
subcategories: parsedMetadata.subcategories || [],
@@ -68,7 +68,7 @@ function extractOutputContent(content: string): string {
return content.slice(outputStart + 8, outputEnd).trim();
}
-function isValidMetadata(metadata: Metadata): boolean {
+function isValidMetadata(metadata: PromptMetadata): boolean {
if (!metadata.title || !metadata.description || !metadata.primary_category) {
logger.warn('Missing one or more required fields in metadata: title, description, or primary_category');
return false;
@@ -80,36 +80,3 @@ function isValidMetadata(metadata: Metadata): boolean {
}
return true;
}
-
-export async function analyzePrompt(promptContent: string): Promise {
- try {
- logger.info('Starting prompt analysis');
- const metadata = await processMetadataGeneration(promptContent);
- logger.info('Prompt analysis completed successfully');
- return metadata;
- } catch (error) {
- logger.error('Error analyzing prompt:', error);
- throw error;
- }
-}
-
-if (require.main === module) {
- // This block will be executed if the script is run directly
- const promptPath = process.argv[2];
-
- if (!promptPath) {
- console.error('Please provide a path to the prompt file as an argument');
- process.exit(1);
- }
-
- readFileContent(promptPath)
- .then(analyzePrompt)
- .then((metadata) => {
- console.log('Generated Metadata:');
- console.log(JSON.stringify(metadata, null, 2));
- })
- .catch((error) => {
- console.error('Error:', error);
- process.exit(1);
- });
-}
diff --git a/src/app/utils/prompt-analyzer-cli.ts b/src/app/utils/prompt-analyzer-cli.ts
new file mode 100644
index 0000000..f00b8d2
--- /dev/null
+++ b/src/app/utils/prompt-analyzer-cli.ts
@@ -0,0 +1,26 @@
+import { analyzePrompt } from './analyze-prompt';
+import { readFileContent } from '../../shared/utils/file-system';
+
+export async function runPromptAnalyzerFromCLI(args: string[]): Promise {
+ const promptPath = args[0];
+
+ if (!promptPath) {
+ console.error('Please provide a path to the prompt file as an argument');
+ process.exit(1);
+ }
+
+ try {
+ const promptContent = await readFileContent(promptPath);
+ const metadata = await analyzePrompt(promptContent);
+ console.log('Generated Metadata:');
+ console.log(JSON.stringify(metadata, null, 2));
+ process.exit(0);
+ } catch (error) {
+ console.error('Error:', error);
+ process.exit(1);
+ }
+}
+
+if (require.main === module) {
+ runPromptAnalyzerFromCLI(process.argv.slice(2));
+}
diff --git a/src/app/utils/yaml_operations.util.ts b/src/app/utils/yaml-operations.ts
similarity index 63%
rename from src/app/utils/yaml_operations.util.ts
rename to src/app/utils/yaml-operations.ts
index 32449b2..82b59b8 100644
--- a/src/app/utils/yaml_operations.util.ts
+++ b/src/app/utils/yaml-operations.ts
@@ -1,21 +1,15 @@
import * as yaml from 'js-yaml';
-import { Metadata, Variable } from '../../shared/types';
-import logger from '../../shared/utils/logger.util';
-import { appConfig } from '../config/app.config';
-
-/**
- * Parses YAML content into a Metadata object.
- * @param {string} yamlContent - The YAML content to parse.
- * @returns {Metadata} The parsed Metadata object.
- * @throws {Error} If parsing fails.
- */
-export function parseYamlContent(yamlContent: string): Metadata {
+import { PromptMetadata, Variable } from '../../shared/types';
+import logger from '../../shared/utils/logger';
+import { appConfig } from '../config/app-config';
+
+export function parseYamlContent(yamlContent: string): PromptMetadata {
try {
logger.debug('Preparing content for YAML parsing');
yamlContent = yamlContent.replace(/^\s*<[^>]+>\s*([\s\S]*?)\s*<\/[^>]+>\s*$/, '$1');
logger.debug('Parsing YAML content');
- const parsedContent = yaml.load(yamlContent) as Metadata;
+ const parsedContent = yaml.load(yamlContent) as PromptMetadata;
logger.debug('YAML content parsed successfully');
return parsedContent;
} catch (error) {
@@ -24,13 +18,7 @@ export function parseYamlContent(yamlContent: string): Metadata {
}
}
-/**
- * Dumps a Metadata object into a YAML string.
- * @param {Metadata} data - The Metadata object to dump.
- * @returns {string} The YAML string representation of the Metadata.
- * @throws {Error} If dumping fails.
- */
-export function dumpYamlContent(data: Metadata): string {
+export function dumpYamlContent(data: PromptMetadata): string {
try {
logger.debug('Dumping Metadata to YAML');
const yamlString = yaml.dump(data, {
@@ -46,20 +34,15 @@ export function dumpYamlContent(data: Metadata): string {
}
}
-/**
- * Validates that a parsed YAML object conforms to the Metadata interface.
- * @param {unknown} obj - The object to validate.
- * @returns {obj is Metadata} True if the object is valid Metadata, false otherwise.
- */
-export function isValidMetadata(obj: unknown): obj is Metadata {
- const metadata = obj as Partial;
+export function isValidMetadata(obj: unknown): obj is PromptMetadata {
+ const metadata = obj as Partial;
if (typeof metadata !== 'object' || metadata === null) {
logger.error('Invalid Metadata: not an object');
return false;
}
- const requiredStringFields: (keyof Metadata)[] = [
+ const requiredStringFields: (keyof PromptMetadata)[] = [
'title',
'primary_category',
'directory',
@@ -74,7 +57,7 @@ export function isValidMetadata(obj: unknown): obj is Metadata {
}
}
- const requiredArrayFields: (keyof Metadata)[] = ['subcategories', 'tags', 'variables'];
+ const requiredArrayFields: (keyof PromptMetadata)[] = ['subcategories', 'tags', 'variables'];
for (const field of requiredArrayFields) {
if (!Array.isArray(metadata[field])) {
@@ -90,11 +73,6 @@ export function isValidMetadata(obj: unknown): obj is Metadata {
return true;
}
-/**
- * Validates that an object conforms to the Variable interface.
- * @param {unknown} obj - The object to validate.
- * @returns {obj is Variable} True if the object is a valid Variable, false otherwise.
- */
function isValidVariable(obj: unknown): obj is Variable {
const variable = obj as Partial;
return (
@@ -105,13 +83,7 @@ function isValidVariable(obj: unknown): obj is Variable {
);
}
-/**
- * Parses YAML content and validates it as Metadata.
- * @param {string} yamlContent - The YAML content to parse and validate.
- * @returns {Metadata} The validated Metadata object.
- * @throws {Error} If parsing fails or the content is not valid Metadata.
- */
-export function parseAndValidateYamlContent(yamlContent: string): Metadata {
+export function parseAndValidateYamlContent(yamlContent: string): PromptMetadata {
const parsedContent = parseYamlContent(yamlContent);
if (!isValidMetadata(parsedContent)) {
@@ -121,15 +93,26 @@ export function parseAndValidateYamlContent(yamlContent: string): Metadata {
return parsedContent;
}
-/**
- * Sanitizes YAML content by removing extra spaces and ensuring proper formatting.
- * @param {string} content - The YAML content to sanitize.
- * @returns {string} Sanitized YAML content.
- */
export function sanitizeYamlContent(content: string): string {
try {
logger.debug('Sanitizing YAML content');
- const parsedContent = yaml.load(content);
+
+ if (content.includes('content_hash:')) {
+ return content.trim() + '\n';
+ }
+
+ let parsedContent;
+
+ try {
+ parsedContent = JSON.parse(content);
+ } catch {
+ try {
+ parsedContent = yaml.load(content);
+ } catch {
+ return content.trim() + '\n';
+ }
+ }
+
const sanitizedContent = yaml.dump(parsedContent, {
indent: appConfig.YAML_INDENT,
lineWidth: appConfig.YAML_LINE_WIDTH,
@@ -144,11 +127,6 @@ export function sanitizeYamlContent(content: string): string {
}
}
-/**
- * Determines if a YAML content needs sanitization.
- * @param {string} content - The YAML content to check.
- * @returns {boolean} True if the content needs sanitization, false otherwise.
- */
export function needsSanitization(content: string): boolean {
try {
const originalParsed = yaml.load(content);
@@ -157,7 +135,7 @@ export function needsSanitization(content: string): boolean {
return JSON.stringify(originalParsed) !== JSON.stringify(sanitizedParsed);
} catch (error) {
logger.error('Error checking YAML sanitization:', error);
- return true; // If we can't parse it, it probably needs sanitization
+ return true;
}
}
diff --git a/src/cli/commands/base.command.ts b/src/cli/commands/base-command.ts
similarity index 95%
rename from src/cli/commands/base.command.ts
rename to src/cli/commands/base-command.ts
index cf41e3b..aacbad8 100644
--- a/src/cli/commands/base.command.ts
+++ b/src/cli/commands/base-command.ts
@@ -7,10 +7,10 @@ import { Command } from 'commander';
import fs from 'fs-extra';
import { ApiResult } from '../../shared/types';
-import { cliConfig } from '../cli.config';
-import { ENV_PREFIX, FRAGMENT_PREFIX } from '../cli.constants';
-import { handleApiResult } from '../utils/database.util';
-import { handleError } from '../utils/error.util';
+import { cliConfig } from '../config/cli-config';
+import { ENV_PREFIX, FRAGMENT_PREFIX } from '../constants';
+import { handleApiResult } from '../utils/database';
+import { handleError } from '../utils/errors';
export class BaseCommand extends Command {
constructor(name: string, description: string) {
diff --git a/src/cli/commands/config.command.ts b/src/cli/commands/config-command.ts
similarity index 98%
rename from src/cli/commands/config.command.ts
rename to src/cli/commands/config-command.ts
index b15ccbb..174f6f9 100644
--- a/src/cli/commands/config.command.ts
+++ b/src/cli/commands/config-command.ts
@@ -1,6 +1,6 @@
import chalk from 'chalk';
-import { BaseCommand } from './base.command';
+import { BaseCommand } from './base-command';
import { Config, getConfig, setConfig } from '../../shared/config';
class ConfigCommand extends BaseCommand {
diff --git a/src/cli/commands/env.command.ts b/src/cli/commands/env-command.ts
similarity index 96%
rename from src/cli/commands/env.command.ts
rename to src/cli/commands/env-command.ts
index 104ec0f..961c1a4 100644
--- a/src/cli/commands/env.command.ts
+++ b/src/cli/commands/env-command.ts
@@ -1,12 +1,12 @@
import chalk from 'chalk';
-import { BaseCommand } from './base.command';
+import { BaseCommand } from './base-command';
import { EnvVar, Fragment } from '../../shared/types';
-import { formatTitleCase, formatSnakeCase } from '../../shared/utils/string_formatter.util';
-import { FRAGMENT_PREFIX } from '../cli.constants';
-import { createEnvVar, readEnvVars, updateEnvVar, deleteEnvVar } from '../utils/env.util';
-import { listFragments, viewFragmentContent } from '../utils/fragment_operations.util';
-import { listPrompts, getPromptFiles } from '../utils/prompt_crud.util';
+import { formatTitleCase, formatSnakeCase } from '../../shared/utils/string-formatter';
+import { FRAGMENT_PREFIX } from '../constants';
+import { createEnvVar, readEnvVars, updateEnvVar, deleteEnvVar } from '../utils/env-vars';
+import { listFragments, viewFragmentContent } from '../utils/fragments';
+import { listPrompts, getPromptFiles } from '../utils/prompts';
class EnvCommand extends BaseCommand {
constructor() {
@@ -209,6 +209,10 @@ class EnvCommand extends BaseCommand {
const uniqueVariables = new Map();
for (const prompt of prompts) {
+ if (!prompt.id) {
+ return [];
+ }
+
const details = await this.handleApiResult(
await getPromptFiles(prompt.id),
`Fetched details for prompt ${prompt.id}`
diff --git a/src/cli/commands/execute.command.ts b/src/cli/commands/execute-command.ts
similarity index 87%
rename from src/cli/commands/execute.command.ts
rename to src/cli/commands/execute-command.ts
index a7fc44c..2679658 100644
--- a/src/cli/commands/execute.command.ts
+++ b/src/cli/commands/execute-command.ts
@@ -2,11 +2,10 @@ import chalk from 'chalk';
import fs from 'fs-extra';
import yaml from 'js-yaml';
-import { BaseCommand } from './base.command';
-import { Metadata, Prompt, Variable } from '../../shared/types';
-import { processPromptContent, updatePromptWithVariables } from '../../shared/utils/prompt_processing.util';
-import { getPromptFiles } from '../utils/prompt_crud.util';
-import { viewPromptDetails } from '../utils/prompt_display.util';
+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() {
@@ -156,7 +155,7 @@ Note:
try {
const promptContent = await fs.readFile(promptFile, 'utf-8');
const metadataContent = await fs.readFile(metadataFile, 'utf-8');
- const metadata = yaml.load(metadataContent) as Metadata;
+ const metadata = yaml.load(metadataContent) as PromptMetadata;
if (inspect) {
await this.inspectPrompt(metadata);
@@ -168,7 +167,7 @@ Note:
}
}
- private async inspectPrompt(metadata: Metadata): Promise {
+ private async inspectPrompt(metadata: PromptMetadata): Promise {
try {
await viewPromptDetails(
{
@@ -178,7 +177,7 @@ Note:
description: metadata.description,
tags: metadata.tags,
variables: metadata.variables
- } as Prompt & { variables: Variable[] },
+ } as PromptMetadata & { variables: Variable[] },
true
);
} catch (error) {
@@ -188,13 +187,26 @@ Note:
private async executePromptWithMetadata(
promptContent: string,
- metadata: Metadata,
+ 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];
@@ -205,13 +217,12 @@ Note:
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;
- } else if (!variable.optional_for_user) {
- throw new Error(`Required variable ${snakeCaseName} is not set.`);
}
}
diff --git a/src/cli/commands/flush.command.ts b/src/cli/commands/flush-command.ts
similarity index 92%
rename from src/cli/commands/flush.command.ts
rename to src/cli/commands/flush-command.ts
index 0fa79e0..38e67d6 100644
--- a/src/cli/commands/flush.command.ts
+++ b/src/cli/commands/flush-command.ts
@@ -1,7 +1,7 @@
import chalk from 'chalk';
-import { BaseCommand } from './base.command';
-import { flushData } from '../utils/database.util';
+import { BaseCommand } from './base-command';
+import { flushData } from '../utils/database';
class FlushCommand extends BaseCommand {
constructor() {
diff --git a/src/cli/commands/fragments.command.ts b/src/cli/commands/fragments-command.ts
similarity index 96%
rename from src/cli/commands/fragments.command.ts
rename to src/cli/commands/fragments-command.ts
index a47869c..0f36130 100644
--- a/src/cli/commands/fragments.command.ts
+++ b/src/cli/commands/fragments-command.ts
@@ -1,9 +1,9 @@
import chalk from 'chalk';
-import { BaseCommand } from './base.command';
+import { BaseCommand } from './base-command';
import { Fragment } from '../../shared/types';
-import { formatTitleCase } from '../../shared/utils/string_formatter.util';
-import { listFragments, viewFragmentContent } from '../utils/fragment_operations.util';
+import { formatTitleCase } from '../../shared/utils/string-formatter';
+import { listFragments, viewFragmentContent } from '../utils/fragments';
type FragmentMenuAction = 'all' | 'category' | 'back';
diff --git a/src/cli/commands/menu.command.ts b/src/cli/commands/menu-command.ts
similarity index 93%
rename from src/cli/commands/menu.command.ts
rename to src/cli/commands/menu-command.ts
index b938d1d..e6ebc1e 100644
--- a/src/cli/commands/menu.command.ts
+++ b/src/cli/commands/menu-command.ts
@@ -1,10 +1,10 @@
import chalk from 'chalk';
import { Command } from 'commander';
-import { BaseCommand } from './base.command';
+import { BaseCommand } from './base-command';
import { getConfig } from '../../shared/config';
-import { handleError } from '../utils/error.util';
-import { hasFragments, hasPrompts } from '../utils/file_system.util';
+import { handleError } from '../utils/errors';
+import { hasFragments, hasPrompts } from '../utils/file-system';
type MenuAction = 'sync' | 'prompts' | 'fragments' | 'settings' | 'env' | 'back';
diff --git a/src/cli/commands/prompts.command.ts b/src/cli/commands/prompts-command.ts
similarity index 96%
rename from src/cli/commands/prompts.command.ts
rename to src/cli/commands/prompts-command.ts
index 83119c5..d4ee6f6 100644
--- a/src/cli/commands/prompts.command.ts
+++ b/src/cli/commands/prompts-command.ts
@@ -1,14 +1,14 @@
import chalk from 'chalk';
-import { BaseCommand } from './base.command';
-import { CategoryItem, EnvVar, Fragment, Prompt, Variable } from '../../shared/types';
-import { formatTitleCase, formatSnakeCase } from '../../shared/utils/string_formatter.util';
-import { ENV_PREFIX, FRAGMENT_PREFIX } from '../cli.constants';
-import { ConversationManager } from '../utils/conversation_manager.util';
-import { fetchCategories, getPromptDetails, updatePromptVariable } from '../utils/database.util';
-import { readEnvVars } from '../utils/env.util';
-import { listFragments, viewFragmentContent } from '../utils/fragment_operations.util';
-import { viewPromptDetails } from '../utils/prompt_display.util';
+import { BaseCommand } from './base-command';
+import { CategoryItem, EnvVar, Fragment, PromptMetadata, Variable } 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 { listFragments, viewFragmentContent } from '../utils/fragments';
+import { viewPromptDetails } from '../utils/prompts';
type PromptMenuAction = 'all' | 'category' | 'id' | 'back';
type SelectPromptMenuAction = Variable | 'execute' | 'unset_all' | 'back';
@@ -183,7 +183,7 @@ class PromptCommand extends BaseCommand {
}
}
- private async selectPromptAction(details: Prompt & { variables: Variable[] }): Promise {
+ 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);
diff --git a/src/cli/commands/settings.command.ts b/src/cli/commands/settings-command.ts
similarity index 89%
rename from src/cli/commands/settings.command.ts
rename to src/cli/commands/settings-command.ts
index 3a8cf18..6e04b45 100644
--- a/src/cli/commands/settings.command.ts
+++ b/src/cli/commands/settings-command.ts
@@ -1,7 +1,7 @@
-import { BaseCommand } from './base.command';
-import ConfigCommand from './config.command';
-import FlushCommand from './flush.command';
-import SyncCommand from './sync.command';
+import { BaseCommand } from './base-command';
+import ConfigCommand from './config-command';
+import FlushCommand from './flush-command';
+import SyncCommand from './sync-command';
type SettingsAction = 'config' | 'sync' | 'flush' | 'back';
diff --git a/src/cli/commands/sync.command.ts b/src/cli/commands/sync-command.ts
similarity index 97%
rename from src/cli/commands/sync.command.ts
rename to src/cli/commands/sync-command.ts
index 49b55b1..4bb3cc5 100644
--- a/src/cli/commands/sync.command.ts
+++ b/src/cli/commands/sync-command.ts
@@ -4,11 +4,11 @@ import chalk from 'chalk';
import fs from 'fs-extra';
import simpleGit, { SimpleGit } from 'simple-git';
-import { BaseCommand } from './base.command';
+import { BaseCommand } from './base-command';
import { getConfig, setConfig } from '../../shared/config';
-import logger from '../../shared/utils/logger.util';
-import { cliConfig } from '../cli.config';
-import { syncPromptsWithDatabase, cleanupOrphanedData } from '../utils/database.util';
+import logger from '../../shared/utils/logger';
+import { cliConfig } from '../config/cli-config';
+import { syncPromptsWithDatabase, cleanupOrphanedData } from '../utils/database';
class SyncCommand extends BaseCommand {
constructor() {
diff --git a/src/cli/cli.config.ts b/src/cli/config/cli-config.ts
similarity index 71%
rename from src/cli/cli.config.ts
rename to src/cli/config/cli-config.ts
index 080fe06..4427ee2 100644
--- a/src/cli/cli.config.ts
+++ b/src/cli/config/cli-config.ts
@@ -1,6 +1,6 @@
import * as path from 'path';
-import { CONFIG_DIR } from '../shared/config/config.constants';
+import { CONFIG_DIR } from '../../shared/config/constants';
export interface CliConfig {
PROMPTS_DIR: string;
@@ -11,8 +11,8 @@ export interface CliConfig {
}
export const cliConfig: CliConfig = {
- PROMPTS_DIR: path.join(CONFIG_DIR, 'prompts'),
- FRAGMENTS_DIR: path.join(CONFIG_DIR, 'fragments'),
+ PROMPTS_DIR: 'prompts',
+ FRAGMENTS_DIR: 'fragments',
DB_PATH: path.join(CONFIG_DIR, 'prompts.sqlite'),
TEMP_DIR: path.join(CONFIG_DIR, 'temp'),
MENU_PAGE_SIZE: process.env.MENU_PAGE_SIZE ? parseInt(process.env.MENU_PAGE_SIZE, 10) : 20
diff --git a/src/cli/cli.constants.ts b/src/cli/constants.ts
similarity index 100%
rename from src/cli/cli.constants.ts
rename to src/cli/constants.ts
diff --git a/src/cli/index.ts b/src/cli/index.ts
index 54b254f..6efd72e 100644
--- a/src/cli/index.ts
+++ b/src/cli/index.ts
@@ -4,16 +4,16 @@ import { Command } from 'commander';
import dotenv from 'dotenv';
import { getConfigValue, setConfig } from '../shared/config';
-import configCommand from './commands/config.command';
-import envCommand from './commands/env.command';
-import executeCommand from './commands/execute.command';
-import flushCommand from './commands/flush.command';
-import fragmentsCommand from './commands/fragments.command';
-import { 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.util';
+import configCommand from './commands/config-command';
+import envCommand from './commands/env-command';
+import executeCommand from './commands/execute-command';
+import flushCommand from './commands/flush-command';
+import fragmentsCommand from './commands/fragments-command';
+import { 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';
process.env.CLI_ENV = 'cli';
diff --git a/src/cli/utils/__tests__/__snapshots__/prompts.test.ts.snap b/src/cli/utils/__tests__/__snapshots__/prompts.test.ts.snap
new file mode 100644
index 0000000..c636f79
--- /dev/null
+++ b/src/cli/utils/__tests__/__snapshots__/prompts.test.ts.snap
@@ -0,0 +1,79 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`PromptsUtils viewPromptDetails should display env variable correctly 1`] = `
+"Prompt: Test Prompt
+
+Full test description
+
+Category: Test
+
+Tags: tag1, tag2
+
+Options: ([*] Required [ ] Optional)
+ --var1 [*]
+ test role
+ Env: env_var_name (env-value)
+"
+`;
+
+exports[`PromptsUtils viewPromptDetails should display fragment variable correctly 1`] = `
+"Prompt: Test Prompt
+
+Full test description
+
+Category: Test
+
+Tags: tag1, tag2
+
+Options: ([*] Required [ ] Optional)"
+`;
+
+exports[`PromptsUtils viewPromptDetails should display prompt details correctly 1`] = `
+"Prompt: Test Prompt
+
+Full test description
+
+Category: Test
+
+Tags: tag1, tag2
+
+Options: ([*] Required [ ] Optional)
+ --var1 [*] (Env variable available)
+ test role
+ Not Set (Required)
+ --var2 [ ]
+ test role 2
+ Not Set
+"
+`;
+
+exports[`PromptsUtils viewPromptDetails should display regular variable value correctly 1`] = `
+"Prompt: Test Prompt
+
+Full test description
+
+Category: Test
+
+Tags: tag1, tag2
+
+Options: ([*] Required [ ] Optional)"
+`;
+
+exports[`PromptsUtils viewPromptDetails should handle env vars fetch failure 1`] = `
+"Prompt: Test Prompt
+
+Full test description
+
+Category: Test
+
+Tags: tag1, tag2
+
+Options: ([*] Required [ ] Optional)
+ --var1 [*]
+ test role
+ Not Set (Required)
+ --var2 [ ]
+ test role 2
+ Not Set
+"
+`;
diff --git a/src/cli/utils/__tests__/conversation-manager.test.ts b/src/cli/utils/__tests__/conversation-manager.test.ts
new file mode 100644
index 0000000..0411ed8
--- /dev/null
+++ b/src/cli/utils/__tests__/conversation-manager.test.ts
@@ -0,0 +1,94 @@
+import { processPromptContent } from '../../../shared/utils/prompt-processing';
+import { ConversationManager } from '../conversation-manager';
+import { resolveInputs } from '../input-resolver';
+import { getPromptFiles } from '../prompts';
+
+jest.mock('../prompts');
+jest.mock('../input-resolver');
+jest.mock('../../../shared/utils/prompt-processing');
+jest.mock('../errors', () => ({
+ handleError: jest.fn()
+}));
+
+describe('ConversationManagerUtils', () => {
+ let conversationManager: ConversationManager;
+ const mockPromptId = 'test-prompt';
+ beforeEach(() => {
+ conversationManager = new ConversationManager(mockPromptId);
+ jest.clearAllMocks();
+ });
+
+ describe('initializeConversation', () => {
+ it('should successfully initialize conversation with user inputs', async () => {
+ const mockUserInputs = { key: 'value' };
+ const mockResolvedInputs = { key: 'resolved-value' };
+ const mockPromptContent = 'Hello {{key}}';
+ const mockResponse = 'Assistant response';
+ (getPromptFiles as jest.Mock).mockResolvedValue({
+ success: true,
+ data: { promptContent: mockPromptContent }
+ });
+ (resolveInputs as jest.Mock).mockResolvedValue(mockResolvedInputs);
+ (processPromptContent as jest.Mock).mockResolvedValue(mockResponse);
+
+ const result = await conversationManager.initializeConversation(mockUserInputs);
+ expect(result).toEqual({
+ success: true,
+ data: mockResponse
+ });
+ expect(getPromptFiles).toHaveBeenCalledWith(mockPromptId);
+ expect(resolveInputs).toHaveBeenCalledWith(mockUserInputs);
+ expect(processPromptContent).toHaveBeenCalled();
+ });
+
+ it('should handle failed prompt files retrieval', async () => {
+ (getPromptFiles as jest.Mock).mockResolvedValue({
+ success: false,
+ error: 'Failed to get prompts'
+ });
+
+ const result = await conversationManager.initializeConversation({});
+ expect(result).toEqual({
+ success: false,
+ error: 'Failed to get prompts'
+ });
+ });
+
+ it('should handle errors during initialization', async () => {
+ const mockError = new Error('Test error');
+ (getPromptFiles as jest.Mock).mockRejectedValue(mockError);
+
+ const result = await conversationManager.initializeConversation({});
+ expect(result).toEqual({
+ success: false,
+ error: 'Failed to initialize conversation'
+ });
+ });
+ });
+
+ describe('continueConversation', () => {
+ it('should successfully continue conversation', async () => {
+ const mockUserInput = 'Hello';
+ const mockResponse = 'Assistant response';
+ (processPromptContent as jest.Mock).mockResolvedValue(mockResponse);
+
+ const result = await conversationManager.continueConversation(mockUserInput);
+ expect(result).toEqual({
+ success: true,
+ data: mockResponse
+ });
+ expect(processPromptContent).toHaveBeenCalled();
+ });
+
+ it('should handle errors during conversation continuation', async () => {
+ const mockError = new Error('Test error');
+ (processPromptContent as jest.Mock).mockRejectedValue(mockError);
+
+ const result = await conversationManager.continueConversation('test');
+ expect(result).toEqual({
+ success: false,
+ error: 'Failed to continue conversation'
+ });
+ });
+ });
+});
diff --git a/src/cli/utils/__tests__/database.test.ts b/src/cli/utils/__tests__/database.test.ts
new file mode 100644
index 0000000..2e39ee7
--- /dev/null
+++ b/src/cli/utils/__tests__/database.test.ts
@@ -0,0 +1,564 @@
+import path from 'path';
+
+import { jest } from '@jest/globals';
+import fs from 'fs-extra';
+import yaml from 'js-yaml';
+import NodeCache from 'node-cache';
+import sqlite3, { RunResult } from 'sqlite3';
+
+import { PromptMetadata } from '../../../shared/types';
+import { fileExists, readDirectory, readFileContent } from '../../../shared/utils/file-system';
+import { cliConfig } from '../../config/cli-config';
+import {
+ runAsync,
+ getAsync,
+ allAsync,
+ handleApiResult,
+ getCachedOrFetch,
+ initDatabase,
+ fetchCategories,
+ getPromptDetails,
+ updatePromptVariable,
+ syncPromptsWithDatabase,
+ cleanupOrphanedData,
+ flushData,
+ db
+} from '../database';
+import { createPrompt } from '../prompts';
+
+jest.mock('fs-extra');
+jest.mock('js-yaml');
+jest.mock('node-cache');
+jest.mock('sqlite3');
+jest.mock('../errors');
+jest.mock('../prompts');
+jest.mock('../../../shared/utils/file-system');
+jest.mock('../../../shared/utils/logger');
+
+const mockFs = fs as jest.Mocked;
+const mockYaml = yaml as jest.Mocked;
+const mockCreatePrompt = createPrompt as jest.MockedFunction;
+const mockFileExists = fileExists as jest.MockedFunction;
+const mockReadDirectory = readDirectory as jest.MockedFunction;
+const mockReadFileContent = readFileContent as jest.MockedFunction;
+describe('DatabaseUtils', () => {
+ let runSpy: jest.SpiedFunction;
+ let getSpy: jest.SpiedFunction;
+ let allSpy: jest.SpiedFunction;
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ runSpy = jest.spyOn(db, 'run').mockImplementation(function (
+ this: sqlite3.Database,
+ sql: string,
+ paramsOrCallback?: any[] | ((this: RunResult, err: Error | null) => void),
+ callback?: (this: RunResult, err: Error | null) => void
+ ): sqlite3.Database {
+ if (typeof paramsOrCallback === 'function') {
+ callback = paramsOrCallback;
+ }
+
+ if (callback) {
+ callback.call({ lastID: 1, changes: 1 } as RunResult, null);
+ }
+ return this;
+ });
+
+ getSpy = jest.spyOn(db, 'get').mockImplementation(function (
+ this: sqlite3.Database,
+ sql: string,
+ paramsOrCallback?: any[] | ((err: Error | null, row?: any) => void),
+ callback?: (err: Error | null, row?: any) => void
+ ): sqlite3.Database {
+ if (typeof paramsOrCallback === 'function') {
+ callback = paramsOrCallback;
+ }
+
+ if (callback) {
+ callback(null, { id: 1, name: 'Test' });
+ }
+ return this;
+ });
+
+ allSpy = jest.spyOn(db, 'all').mockImplementation(function (
+ this: sqlite3.Database,
+ sql: string,
+ paramsOrCallback?: any[] | ((err: Error | null, rows?: any[]) => void),
+ callback?: (err: Error | null, rows?: any[]) => void
+ ): sqlite3.Database {
+ if (typeof paramsOrCallback === 'function') {
+ callback = paramsOrCallback;
+ }
+
+ if (callback) {
+ callback(null, [{ id: 1 }, { id: 2 }]);
+ }
+ return this;
+ });
+ });
+
+ describe('runAsync', () => {
+ it('should execute SQL run command successfully', async () => {
+ const result = await runAsync('INSERT INTO test_table VALUES (?)', ['test']);
+ expect(result.success).toBe(true);
+ expect(result.data).toEqual({ lastID: 1, changes: 1 });
+ });
+
+ it('should handle SQL run command error', async () => {
+ runSpy.mockImplementation(function (
+ this: sqlite3.Database,
+ sql: string,
+ paramsOrCallback?: any[] | ((this: RunResult, err: Error | null) => void),
+ callback?: (this: RunResult, err: Error | null) => void
+ ): sqlite3.Database {
+ if (typeof paramsOrCallback === 'function') {
+ callback = paramsOrCallback;
+ }
+
+ if (callback) {
+ callback.call({} as RunResult, new Error('SQL error'));
+ }
+ return this;
+ });
+
+ const result = await runAsync('INVALID SQL', []);
+ expect(result.success).toBe(false);
+ expect(result.error).toBe('SQL error');
+ });
+ });
+
+ describe('getAsync', () => {
+ it('should execute SQL get command successfully', async () => {
+ const result = await getAsync<{ id: number; name: string }>('SELECT * FROM test_table WHERE id = ?', [1]);
+ expect(result.success).toBe(true);
+ expect(result.data).toEqual({ id: 1, name: 'Test' });
+ });
+
+ it('should handle SQL get command error', async () => {
+ getSpy.mockImplementation(function (
+ this: sqlite3.Database,
+ sql: string,
+ paramsOrCallback?: any[] | ((err: Error | null, row?: any) => void),
+ callback?: (err: Error | null, row?: any) => void
+ ): sqlite3.Database {
+ if (typeof paramsOrCallback === 'function') {
+ callback = paramsOrCallback;
+ }
+
+ if (callback) {
+ callback(new Error('SQL error'), undefined);
+ }
+ return this;
+ });
+
+ const result = await getAsync('INVALID SQL', []);
+ expect(result.success).toBe(false);
+ expect(result.error).toBe('SQL error');
+ });
+ });
+
+ describe('allAsync', () => {
+ it('should execute SQL all command successfully', async () => {
+ const result = await allAsync<{ id: number }>('SELECT * FROM test_table', []);
+ expect(result.success).toBe(true);
+ expect(result.data).toEqual([{ id: 1 }, { id: 2 }]);
+ });
+
+ it('should handle SQL all command error', async () => {
+ allSpy.mockImplementation(function (
+ this: sqlite3.Database,
+ sql: string,
+ paramsOrCallback?: any[] | ((err: Error | null, rows?: any[]) => void),
+ callback?: (err: Error | null, rows?: any[]) => void
+ ): sqlite3.Database {
+ if (typeof paramsOrCallback === 'function') {
+ callback = paramsOrCallback;
+ }
+
+ if (callback) {
+ callback(new Error('SQL error'), undefined);
+ }
+ return this;
+ });
+
+ const result = await allAsync('INVALID SQL', []);
+ expect(result.success).toBe(false);
+ expect(result.error).toBe('SQL error');
+ });
+ });
+
+ describe('handleApiResult', () => {
+ it('should return data if result is successful', async () => {
+ const result = await handleApiResult({ success: true, data: 'Test Data' }, 'Test Message');
+ expect(result).toBe('Test Data');
+ });
+
+ it('should handle error if result is not successful', async () => {
+ const result = await handleApiResult({ success: false, error: 'Test Error' }, 'Test Message');
+ expect(result).toBeNull();
+ });
+ });
+
+ describe('getCachedOrFetch', () => {
+ let cacheInstance: NodeCache;
+ let cacheGetSpy: jest.SpiedFunction;
+ let cacheSetSpy: jest.SpiedFunction;
+ beforeEach(() => {
+ cacheInstance = new NodeCache();
+ cacheGetSpy = jest.spyOn(cacheInstance, 'get');
+ cacheSetSpy = jest.spyOn(cacheInstance, 'set');
+ });
+
+ afterEach(() => {
+ cacheInstance.flushAll();
+ jest.clearAllMocks();
+ });
+
+ it('should return cached data if available', async () => {
+ cacheGetSpy.mockReturnValue('Cached Data');
+
+ const fetchFn = jest.fn(() => Promise.resolve({ success: true, data: 'Fetched Data' }));
+ const result = await getCachedOrFetch('testKey', fetchFn, cacheInstance);
+ expect(result.success).toBe(true);
+ expect(result.data).toBe('Cached Data');
+ expect(fetchFn).not.toHaveBeenCalled();
+ });
+
+ it('should fetch data if not in cache and cache it', async () => {
+ cacheGetSpy.mockReturnValue(undefined);
+
+ const fetchFn = jest.fn(() => Promise.resolve({ success: true, data: 'Fetched Data' }));
+ const result = await getCachedOrFetch('testKey', fetchFn, cacheInstance);
+ expect(result.success).toBe(true);
+ expect(result.data).toBe('Fetched Data');
+ expect(fetchFn).toHaveBeenCalled();
+ expect(cacheSetSpy).toHaveBeenCalledWith('testKey', 'Fetched Data');
+ });
+
+ it('should handle fetch error', async () => {
+ cacheGetSpy.mockReturnValue(undefined);
+
+ const fetchFn = jest.fn(() => Promise.resolve({ success: false, error: 'Fetch Error' }));
+ const result = await getCachedOrFetch('testKey', fetchFn, cacheInstance);
+ expect(result.success).toBe(false);
+ expect(result.error).toBe('Fetch Error');
+ expect(fetchFn).toHaveBeenCalled();
+ expect(cacheSetSpy).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('initDatabase', () => {
+ it('should initialize the database successfully', async () => {
+ mockFs.ensureDir.mockImplementation(() => Promise.resolve());
+
+ const result = await initDatabase();
+ expect(result.success).toBe(true);
+ expect(mockFs.ensureDir).toHaveBeenCalledWith(path.dirname(cliConfig.DB_PATH));
+ expect(runSpy).toHaveBeenCalledTimes(5);
+ });
+
+ it('should handle errors during database initialization', async () => {
+ mockFs.ensureDir.mockImplementation(() => Promise.reject(new Error('FS Error')));
+
+ const result = await initDatabase();
+ expect(result.success).toBe(false);
+ expect(result.error).toBe('Failed to initialize database');
+ });
+ });
+
+ describe('fetchCategories', () => {
+ it('should fetch categories successfully', async () => {
+ const mockData = [
+ {
+ id: 1,
+ title: 'Test Prompt',
+ primary_category: 'Category1',
+ description: 'Test Description',
+ path: '/test/path',
+ tags: 'tag1,tag2',
+ subcategories: 'sub1,sub2'
+ }
+ ];
+ allSpy.mockImplementationOnce(function (
+ this: sqlite3.Database,
+ sql: string,
+ paramsOrCallback?: any[] | ((err: Error | null, rows?: any[]) => void),
+ callback?: (err: Error | null, rows?: any[]) => void
+ ): sqlite3.Database {
+ if (typeof paramsOrCallback === 'function') {
+ callback = paramsOrCallback;
+ }
+
+ if (callback) {
+ callback(null, mockData);
+ }
+ return this;
+ });
+
+ const result = await fetchCategories();
+ expect(result.success).toBe(true);
+ expect(result.data).toEqual({
+ Category1: [
+ {
+ id: 1,
+ title: 'Test Prompt',
+ primary_category: 'Category1',
+ description: 'Test Description',
+ path: '/test/path',
+ tags: ['tag1', 'tag2'],
+ subcategories: ['sub1', 'sub2']
+ }
+ ]
+ });
+ });
+
+ it('should handle errors when fetching categories', async () => {
+ allSpy.mockImplementationOnce(function (
+ this: sqlite3.Database,
+ sql: string,
+ paramsOrCallback?: any[] | ((err: Error | null, rows?: any[]) => void),
+ callback?: (err: Error | null, rows?: any[]) => void
+ ): sqlite3.Database {
+ if (typeof paramsOrCallback === 'function') {
+ callback = paramsOrCallback;
+ }
+
+ if (callback) {
+ callback(new Error('DB Error'), undefined);
+ }
+ return this;
+ });
+
+ const result = await fetchCategories();
+ expect(result.success).toBe(false);
+ expect(result.error).toBe('Failed to fetch prompts with categories');
+ });
+ });
+
+ describe('getPromptDetails', () => {
+ it('should get prompt details successfully', async () => {
+ getSpy.mockImplementationOnce(function (
+ this: sqlite3.Database,
+ sql: string,
+ paramsOrCallback?: any[] | ((err: Error | null, row?: any) => void),
+ callback?: (err: Error | null, row?: any) => void
+ ): sqlite3.Database {
+ if (typeof paramsOrCallback === 'function') {
+ callback = paramsOrCallback;
+ }
+
+ if (callback) {
+ callback(null, {
+ id: 1,
+ title: 'Test Prompt',
+ content: 'Test Content',
+ tags: ['tag1', 'tag2']
+ });
+ }
+ return this;
+ });
+
+ allSpy.mockImplementationOnce(function (
+ this: sqlite3.Database,
+ sql: string,
+ paramsOrCallback?: any[] | ((err: Error | null, rows?: any[]) => void),
+ callback?: (err: Error | null, rows?: any[]) => void
+ ): sqlite3.Database {
+ if (typeof paramsOrCallback === 'function') {
+ callback = paramsOrCallback;
+ }
+
+ if (callback) {
+ callback(null, [{ name: 'var1', role: 'user', value: '', optional_for_user: false }]);
+ }
+ return this;
+ });
+
+ const result = await getPromptDetails('1');
+ expect(result.success).toBe(true);
+ expect(result.data).toEqual({
+ id: 1,
+ title: 'Test Prompt',
+ content: 'Test Content',
+ tags: ['tag1', 'tag2'],
+ variables: [
+ {
+ name: 'var1',
+ role: 'user',
+ value: '',
+ optional_for_user: false
+ }
+ ]
+ });
+ });
+
+ it('should handle errors when getting prompt details', async () => {
+ getSpy.mockImplementationOnce(function (
+ this: sqlite3.Database,
+ sql: string,
+ paramsOrCallback?: any[] | ((err: Error | null, row?: any) => void),
+ callback?: (err: Error | null, row?: any) => void
+ ): sqlite3.Database {
+ if (typeof paramsOrCallback === 'function') {
+ callback = paramsOrCallback;
+ }
+
+ if (callback) {
+ callback(new Error('DB Error'), undefined);
+ }
+ return this;
+ });
+
+ const result = await getPromptDetails('1');
+ expect(result.success).toBe(false);
+ expect(result.error).toBe('Failed to fetch prompt details');
+ });
+ });
+
+ describe('updatePromptVariable', () => {
+ it('should update prompt variable successfully', async () => {
+ runSpy.mockImplementationOnce(function (
+ this: sqlite3.Database,
+ sql: string,
+ paramsOrCallback?: any[] | ((this: RunResult, err: Error | null) => void),
+ callback?: (this: RunResult, err: Error | null) => void
+ ): sqlite3.Database {
+ if (typeof paramsOrCallback === 'function') {
+ callback = paramsOrCallback;
+ }
+
+ if (callback) {
+ callback.call({ changes: 1 } as RunResult, null);
+ }
+ return this;
+ });
+
+ const result = await updatePromptVariable('1', 'var1', 'newValue');
+ expect(result.success).toBe(true);
+ });
+
+ it('should handle errors when updating prompt variable', async () => {
+ runSpy.mockImplementationOnce(function (
+ this: sqlite3.Database,
+ sql: string,
+ paramsOrCallback?: any[] | ((this: RunResult, err: Error | null) => void),
+ callback?: (this: RunResult, err: Error | null) => void
+ ): sqlite3.Database {
+ if (typeof paramsOrCallback === 'function') {
+ callback = paramsOrCallback;
+ }
+
+ if (callback) {
+ callback.call({ changes: 0 } as RunResult, null);
+ }
+ return this;
+ });
+
+ const result = await updatePromptVariable('1', 'var1', 'newValue');
+ expect(result.success).toBe(false);
+ expect(result.error).toBe('No variable found with name var1 for prompt 1');
+ });
+ });
+
+ describe('syncPromptsWithDatabase', () => {
+ it('should sync prompts with database successfully', async () => {
+ mockReadDirectory.mockResolvedValue(['prompt1', 'prompt2']);
+ mockFileExists.mockResolvedValue(true);
+ mockReadFileContent.mockResolvedValue('content');
+ mockYaml.load.mockReturnValue({
+ title: 'Test Prompt',
+ primary_category: 'Category1'
+ } as PromptMetadata);
+
+ mockCreatePrompt.mockResolvedValue({ success: true });
+
+ const result = await syncPromptsWithDatabase();
+ expect(result.success).toBe(true);
+ expect(runSpy).toHaveBeenNthCalledWith(1, 'DELETE FROM prompts', [], expect.any(Function));
+ expect(runSpy).toHaveBeenNthCalledWith(2, 'DELETE FROM subcategories', [], expect.any(Function));
+ expect(runSpy).toHaveBeenNthCalledWith(3, 'DELETE FROM variables', [], expect.any(Function));
+ expect(runSpy).toHaveBeenNthCalledWith(4, 'DELETE FROM fragments', [], expect.any(Function));
+ expect(mockCreatePrompt).toHaveBeenCalledTimes(2);
+ });
+
+ it('should handle errors during sync', async () => {
+ mockReadDirectory.mockRejectedValue(new Error('FS Error'));
+
+ const result = await syncPromptsWithDatabase();
+ expect(result.success).toBe(false);
+ expect(result.error).toBe('Failed to sync prompts with database');
+ });
+ });
+
+ describe('cleanupOrphanedData', () => {
+ it('should clean up orphaned data successfully', async () => {
+ mockReadDirectory.mockResolvedValue(['1', '2']);
+
+ allSpy.mockImplementationOnce(function (
+ this: sqlite3.Database,
+ sql: string,
+ paramsOrCallback?: any[] | ((err: Error | null, rows?: any[]) => void),
+ callback?: (err: Error | null, rows?: any[]) => void
+ ): sqlite3.Database {
+ if (typeof paramsOrCallback === 'function') {
+ callback = paramsOrCallback;
+ }
+
+ if (callback) {
+ callback(null, []);
+ }
+ return this;
+ });
+
+ const result = await cleanupOrphanedData();
+ expect(result.success).toBe(true);
+ expect(runSpy).toHaveBeenNthCalledWith(
+ 1,
+ 'DELETE FROM prompts WHERE id NOT IN (?)',
+ ['1,2'],
+ expect.any(Function)
+ );
+ expect(runSpy).toHaveBeenNthCalledWith(
+ 2,
+ 'DELETE FROM subcategories WHERE prompt_id NOT IN (SELECT id FROM prompts)',
+ [],
+ expect.any(Function)
+ );
+ expect(runSpy).toHaveBeenNthCalledWith(
+ 3,
+ 'DELETE FROM fragments WHERE prompt_id NOT IN (SELECT id FROM prompts)',
+ [],
+ expect.any(Function)
+ );
+ });
+
+ it('should handle errors during cleanup', async () => {
+ mockReadDirectory.mockRejectedValue(new Error('FS Error'));
+
+ const result = await cleanupOrphanedData();
+ expect(result.success).toBe(false);
+ expect(result.error).toBe('Failed to clean up orphaned data');
+ });
+ });
+
+ describe('flushData', () => {
+ it('should flush data successfully', async () => {
+ mockFs.emptyDir.mockImplementation(() => Promise.resolve());
+
+ await flushData();
+
+ expect(runSpy).toHaveBeenNthCalledWith(1, 'DELETE FROM prompts', [], expect.any(Function));
+ expect(runSpy).toHaveBeenNthCalledWith(2, 'DELETE FROM subcategories', [], expect.any(Function));
+ expect(runSpy).toHaveBeenNthCalledWith(3, 'DELETE FROM variables', [], expect.any(Function));
+ expect(runSpy).toHaveBeenNthCalledWith(4, 'DELETE FROM fragments', [], expect.any(Function));
+ expect(runSpy).toHaveBeenNthCalledWith(5, 'DELETE FROM env_vars', [], expect.any(Function));
+ expect(mockFs.emptyDir).toHaveBeenCalledWith(cliConfig.PROMPTS_DIR);
+ });
+
+ it('should handle errors during data flush', async () => {
+ mockFs.emptyDir.mockImplementation(() => Promise.reject(new Error('FS Error')));
+
+ await expect(flushData()).rejects.toThrow('Failed to flush data');
+ });
+ });
+});
diff --git a/src/cli/utils/__tests__/env-vars.test.ts b/src/cli/utils/__tests__/env-vars.test.ts
new file mode 100644
index 0000000..9d2ef18
--- /dev/null
+++ b/src/cli/utils/__tests__/env-vars.test.ts
@@ -0,0 +1,178 @@
+import { EnvVar } from '../../../shared/types';
+import { runAsync, allAsync } from '../database';
+import { createEnvVar, readEnvVars, updateEnvVar, deleteEnvVar } from '../env-vars';
+
+jest.mock('../database', () => ({
+ runAsync: jest.fn(),
+ allAsync: jest.fn()
+}));
+
+describe('EnvVarsUtils', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest.spyOn(console, 'log').mockImplementation(() => {});
+ jest.spyOn(console, 'error').mockImplementation(() => {});
+ });
+
+ describe('createEnvVar', () => {
+ it('should successfully create an environment variable', async () => {
+ const mockEnvVar: Omit = {
+ name: 'TEST_VAR',
+ value: 'test-value',
+ scope: 'global',
+ prompt_id: undefined
+ };
+ (runAsync as jest.Mock).mockResolvedValue({
+ success: true,
+ data: { lastID: 1 }
+ });
+
+ const result = await createEnvVar(mockEnvVar);
+ expect(result).toEqual({
+ success: true,
+ data: { ...mockEnvVar, id: 1 }
+ });
+ expect(runAsync).toHaveBeenCalledWith(
+ 'INSERT INTO env_vars (name, value, scope, prompt_id) VALUES (?, ?, ?, ?)',
+ ['TEST_VAR', 'test-value', 'global', null]
+ );
+ });
+
+ it('should handle database errors during creation', async () => {
+ const mockEnvVar: Omit = {
+ name: 'TEST_VAR',
+ value: 'test-value',
+ scope: 'global',
+ prompt_id: undefined
+ };
+ (runAsync as jest.Mock).mockRejectedValue(new Error('Database error'));
+
+ const result = await createEnvVar(mockEnvVar);
+ expect(result).toEqual({
+ success: false,
+ error: 'Failed to create environment variable'
+ });
+ });
+ });
+
+ describe('readEnvVars', () => {
+ it('should read all global environment variables', async () => {
+ const mockEnvVars = [
+ { id: 1, name: 'TEST_VAR1', value: 'value1', scope: 'global', prompt_id: null },
+ { id: 2, name: 'TEST_VAR2', value: 'value2', scope: 'global', prompt_id: null }
+ ];
+ (allAsync as jest.Mock).mockResolvedValue({
+ success: true,
+ data: mockEnvVars
+ });
+
+ const result = await readEnvVars();
+ expect(result).toEqual({
+ success: true,
+ data: mockEnvVars
+ });
+ expect(allAsync).toHaveBeenCalledWith('SELECT * FROM env_vars WHERE scope = "global"', []);
+ });
+
+ it('should read environment variables for specific prompt', async () => {
+ const promptId = 123;
+ const mockEnvVars = [{ id: 1, name: 'TEST_VAR1', value: 'value1', scope: 'prompt', prompt_id: promptId }];
+ (allAsync as jest.Mock).mockResolvedValue({
+ success: true,
+ data: mockEnvVars
+ });
+
+ const result = await readEnvVars(promptId);
+ expect(result).toEqual({
+ success: true,
+ data: mockEnvVars
+ });
+ expect(allAsync).toHaveBeenCalledWith(
+ 'SELECT * FROM env_vars WHERE scope = "global" OR (scope = "prompt" AND prompt_id = ?)',
+ [promptId]
+ );
+ });
+
+ it('should handle database errors during read', async () => {
+ (allAsync as jest.Mock).mockRejectedValue(new Error('Database error'));
+
+ const result = await readEnvVars();
+ expect(result).toEqual({
+ success: false,
+ error: 'Failed to read environment variables'
+ });
+ });
+
+ it('should handle unsuccessful database response', async () => {
+ (allAsync as jest.Mock).mockResolvedValue({
+ success: false,
+ data: undefined,
+ error: undefined
+ });
+
+ const result = await readEnvVars();
+ expect(result).toEqual({
+ success: false,
+ error: 'Failed to fetch environment variables'
+ });
+ });
+ });
+
+ describe('updateEnvVar', () => {
+ 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');
+ expect(result).toEqual({ success: true });
+ expect(runAsync).toHaveBeenCalledWith('UPDATE env_vars SET value = ? WHERE id = ?', ['new-value', 1]);
+ });
+
+ it('should handle non-existent environment variable', async () => {
+ (runAsync as jest.Mock).mockResolvedValue({
+ success: true,
+ data: { changes: 0 }
+ });
+
+ const result = await updateEnvVar(999, 'new-value');
+ expect(result).toEqual({
+ success: false,
+ error: 'No environment variable found with id 999'
+ });
+ });
+
+ it('should handle database errors during update', async () => {
+ (runAsync as jest.Mock).mockRejectedValue(new Error('Database error'));
+
+ const result = await updateEnvVar(1, 'new-value');
+ expect(result).toEqual({
+ success: false,
+ error: 'Failed to update environment variable'
+ });
+ });
+ });
+
+ describe('deleteEnvVar', () => {
+ it('should successfully delete an environment variable', async () => {
+ (runAsync as jest.Mock).mockResolvedValue({
+ success: true
+ });
+
+ const result = await deleteEnvVar(1);
+ expect(result).toEqual({ success: true });
+ expect(runAsync).toHaveBeenCalledWith('DELETE FROM env_vars WHERE id = ?', [1]);
+ });
+
+ it('should handle database errors during deletion', async () => {
+ (runAsync as jest.Mock).mockRejectedValue(new Error('Database error'));
+
+ const result = await deleteEnvVar(1);
+ expect(result).toEqual({
+ success: false,
+ error: 'Failed to delete environment variable'
+ });
+ });
+ });
+});
diff --git a/src/cli/utils/__tests__/errors.test.ts b/src/cli/utils/__tests__/errors.test.ts
new file mode 100644
index 0000000..99faca4
--- /dev/null
+++ b/src/cli/utils/__tests__/errors.test.ts
@@ -0,0 +1,76 @@
+import chalk from 'chalk';
+
+import logger from '../../../shared/utils/logger';
+import { AppError, handleError } from '../errors';
+
+jest.mock('../../../shared/utils/logger');
+
+describe('ErrorsUtils', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ jest.spyOn(console, 'error').mockImplementation(() => {});
+ });
+
+ describe('AppError', () => {
+ it('should create an AppError with code and message', () => {
+ const error = new AppError('TEST_ERROR', 'Test error message');
+ expect(error).toBeInstanceOf(Error);
+ expect(error).toBeInstanceOf(AppError);
+ expect(error.code).toBe('TEST_ERROR');
+ expect(error.message).toBe('Test error message');
+ expect(error.name).toBe('AppError');
+ });
+ });
+
+ describe('handleError', () => {
+ const context = 'test context';
+ it('should handle AppError', () => {
+ const error = new AppError('TEST_ERROR', 'Test error message');
+ handleError(error, context);
+
+ expect(logger.error).toHaveBeenCalledWith('Error in test context:');
+ expect(logger.error).toHaveBeenCalledWith('[TEST_ERROR] Test error message');
+ expect(console.error).toHaveBeenCalledWith(
+ chalk.red('Error in test context: [TEST_ERROR] Test error message')
+ );
+ });
+
+ it('should handle standard Error with stack trace', () => {
+ const error = new Error('Standard error');
+ handleError(error, context);
+
+ expect(logger.error).toHaveBeenCalledWith('Error in test context:');
+ expect(logger.error).toHaveBeenCalledWith(error.message);
+ expect(logger.debug).toHaveBeenCalledWith('Stack trace:', error.stack);
+ expect(console.error).toHaveBeenCalledWith(chalk.red(' Message: Standard error'));
+ expect(console.error).toHaveBeenCalledWith(chalk.yellow(' Stack trace:'));
+ });
+
+ it('should handle string error', () => {
+ const errorMessage = 'String error message';
+ handleError(errorMessage, context);
+
+ expect(logger.error).toHaveBeenCalledWith('Error in test context:');
+ expect(logger.error).toHaveBeenCalledWith(errorMessage);
+ expect(console.error).toHaveBeenCalledWith(chalk.red(` ${errorMessage}`));
+ });
+
+ it('should handle unknown error type', () => {
+ const error = { custom: 'error' };
+ handleError(error, context);
+
+ expect(logger.error).toHaveBeenCalledWith('Error in test context:');
+ expect(logger.error).toHaveBeenCalledWith(`Unknown error: ${JSON.stringify(error)}`);
+ expect(console.error).toHaveBeenCalledWith(chalk.red(` Unknown error: ${JSON.stringify(error)}`));
+ });
+
+ it('should always show the report message', () => {
+ handleError('any error', context);
+
+ expect(console.error).toHaveBeenCalledWith(
+ chalk.cyan('\nIf this error persists, please report it to the development team.')
+ );
+ });
+ });
+});
diff --git a/src/cli/utils/__tests__/file-system.test.ts b/src/cli/utils/__tests__/file-system.test.ts
new file mode 100644
index 0000000..e1f7371
--- /dev/null
+++ b/src/cli/utils/__tests__/file-system.test.ts
@@ -0,0 +1,77 @@
+import fs from 'fs-extra';
+
+import { readDirectory } from '../../../shared/utils/file-system';
+import { cliConfig } from '../../config/cli-config';
+import { hasPrompts, hasFragments } from '../file-system';
+
+jest.mock('fs-extra');
+jest.mock('../../../shared/utils/file-system');
+jest.mock('../errors', () => ({
+ handleError: jest.fn()
+}));
+
+describe('FileSystemUtils', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('hasPrompts', () => {
+ it('should return true when prompts directory has contents', async () => {
+ (fs.ensureDir as jest.Mock).mockResolvedValue(undefined);
+ (readDirectory as jest.Mock).mockResolvedValue(['prompt1', 'prompt2']);
+
+ const result = await hasPrompts();
+ expect(result).toBe(true);
+ expect(fs.ensureDir).toHaveBeenCalledWith(cliConfig.PROMPTS_DIR);
+ expect(readDirectory).toHaveBeenCalledWith(cliConfig.PROMPTS_DIR);
+ });
+
+ it('should return false when prompts directory is empty', async () => {
+ (fs.ensureDir as jest.Mock).mockResolvedValue(undefined);
+ (readDirectory as jest.Mock).mockResolvedValue([]);
+
+ const result = await hasPrompts();
+ expect(result).toBe(false);
+ expect(fs.ensureDir).toHaveBeenCalledWith(cliConfig.PROMPTS_DIR);
+ expect(readDirectory).toHaveBeenCalledWith(cliConfig.PROMPTS_DIR);
+ });
+
+ it('should handle errors and return false', async () => {
+ const error = new Error('Test error');
+ (fs.ensureDir as jest.Mock).mockRejectedValue(error);
+
+ const result = await hasPrompts();
+ expect(result).toBe(false);
+ });
+ });
+
+ describe('hasFragments', () => {
+ it('should return true when fragments directory has contents', async () => {
+ (fs.ensureDir as jest.Mock).mockResolvedValue(undefined);
+ (readDirectory as jest.Mock).mockResolvedValue(['fragment1', 'fragment2']);
+
+ const result = await hasFragments();
+ expect(result).toBe(true);
+ expect(fs.ensureDir).toHaveBeenCalledWith(cliConfig.FRAGMENTS_DIR);
+ expect(readDirectory).toHaveBeenCalledWith(cliConfig.FRAGMENTS_DIR);
+ });
+
+ it('should return false when fragments directory is empty', async () => {
+ (fs.ensureDir as jest.Mock).mockResolvedValue(undefined);
+ (readDirectory as jest.Mock).mockResolvedValue([]);
+
+ const result = await hasFragments();
+ expect(result).toBe(false);
+ expect(fs.ensureDir).toHaveBeenCalledWith(cliConfig.FRAGMENTS_DIR);
+ expect(readDirectory).toHaveBeenCalledWith(cliConfig.FRAGMENTS_DIR);
+ });
+
+ it('should handle errors and return false', async () => {
+ const error = new Error('Test error');
+ (fs.ensureDir as jest.Mock).mockRejectedValue(error);
+
+ const result = await hasFragments();
+ expect(result).toBe(false);
+ });
+ });
+});
diff --git a/src/cli/utils/__tests__/fragments.test.ts b/src/cli/utils/__tests__/fragments.test.ts
new file mode 100644
index 0000000..bf151c5
--- /dev/null
+++ b/src/cli/utils/__tests__/fragments.test.ts
@@ -0,0 +1,84 @@
+import path from 'path';
+
+import { jest } from '@jest/globals';
+
+import { Fragment } from '../../../shared/types';
+import { readDirectory, readFileContent } from '../../../shared/utils/file-system';
+import { cliConfig } from '../../config/cli-config';
+import { listFragments, viewFragmentContent } from '../fragments';
+
+jest.mock('../../../shared/utils/file-system');
+jest.mock('../errors', () => ({
+ handleError: jest.fn()
+}));
+
+describe('FragmentsUtils', () => {
+ const mockReadDirectory = readDirectory as jest.MockedFunction;
+ const mockReadFileContent = readFileContent as jest.MockedFunction;
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('listFragments', () => {
+ it('should successfully list fragments', async () => {
+ mockReadDirectory
+ .mockResolvedValueOnce(['category1', 'category2'])
+ .mockResolvedValueOnce(['fragment1.md', 'fragment2.md'])
+ .mockResolvedValueOnce(['fragment3.md']);
+
+ const expectedFragments: Fragment[] = [
+ { category: 'category1', name: 'fragment1', variable: '' },
+ { category: 'category1', name: 'fragment2', variable: '' },
+ { category: 'category2', name: 'fragment3', variable: '' }
+ ];
+ const result = await listFragments();
+ expect(result.success).toBe(true);
+ expect(result.data).toEqual(expectedFragments);
+ expect(mockReadDirectory).toHaveBeenCalledWith(cliConfig.FRAGMENTS_DIR);
+ });
+
+ it('should handle errors when listing fragments', async () => {
+ mockReadDirectory.mockRejectedValueOnce(new Error('Directory read error'));
+
+ const result = await listFragments();
+ expect(result.success).toBe(false);
+ expect(result.error).toBe('Failed to list fragments');
+ });
+
+ it('should ignore non-markdown files', async () => {
+ mockReadDirectory
+ .mockResolvedValueOnce(['category1'])
+ .mockResolvedValueOnce(['fragment1.md', 'fragment2.txt', 'fragment3.md']);
+
+ const expectedFragments: Fragment[] = [
+ { category: 'category1', name: 'fragment1', variable: '' },
+ { category: 'category1', name: 'fragment3', variable: '' }
+ ];
+ const result = await listFragments();
+ expect(result.success).toBe(true);
+ expect(result.data).toEqual(expectedFragments);
+ });
+ });
+
+ describe('viewFragmentContent', () => {
+ it('should successfully read fragment content', async () => {
+ const mockContent = '# Fragment Content';
+ mockReadFileContent.mockResolvedValueOnce(mockContent);
+
+ const result = await viewFragmentContent('category1', 'fragment1');
+ expect(result.success).toBe(true);
+ expect(result.data).toBe(mockContent);
+ expect(mockReadFileContent).toHaveBeenCalledWith(
+ path.join(cliConfig.FRAGMENTS_DIR, 'category1', 'fragment1.md')
+ );
+ });
+
+ it('should handle errors when reading fragment content', async () => {
+ mockReadFileContent.mockRejectedValueOnce(new Error('File read error'));
+
+ const result = await viewFragmentContent('category1', 'fragment1');
+ expect(result.success).toBe(false);
+ expect(result.error).toBe('Failed to view fragment content');
+ });
+ });
+});
diff --git a/src/cli/utils/__tests__/input-resolver.test.ts b/src/cli/utils/__tests__/input-resolver.test.ts
new file mode 100644
index 0000000..bc26d12
--- /dev/null
+++ b/src/cli/utils/__tests__/input-resolver.test.ts
@@ -0,0 +1,122 @@
+import { jest } from '@jest/globals';
+
+import { EnvVar } from '../../../shared/types';
+import { FRAGMENT_PREFIX, ENV_PREFIX } from '../../constants';
+import { readEnvVars } from '../env-vars';
+import { viewFragmentContent } from '../fragments';
+import { resolveValue, resolveInputs } from '../input-resolver';
+
+jest.mock('../env-vars');
+jest.mock('../fragments');
+jest.mock('../errors', () => ({
+ handleError: jest.fn()
+}));
+
+describe('InputResolverUtils', () => {
+ const mockReadEnvVars = readEnvVars as jest.MockedFunction;
+ const mockViewFragmentContent = viewFragmentContent as jest.MockedFunction;
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('resolveValue', () => {
+ const mockEnvVars: EnvVar[] = [
+ { id: 1, name: 'TEST_VAR', value: 'test-value', scope: 'global' },
+ { id: 2, name: 'NESTED_VAR', value: '$env:TEST_VAR', scope: 'global' }
+ ];
+ it('should resolve fragment references', async () => {
+ const fragmentContent = '# Test Fragment Content';
+ mockViewFragmentContent.mockResolvedValueOnce({
+ success: true,
+ data: fragmentContent
+ });
+
+ const result = await resolveValue(`${FRAGMENT_PREFIX}category/fragment`, mockEnvVars);
+ expect(result).toBe(fragmentContent);
+ expect(mockViewFragmentContent).toHaveBeenCalledWith('category', 'fragment');
+ });
+
+ it('should handle failed fragment resolution', async () => {
+ mockViewFragmentContent.mockResolvedValueOnce({
+ success: false,
+ error: 'Fragment not found'
+ });
+
+ const value = `${FRAGMENT_PREFIX}category/nonexistent`;
+ const result = await resolveValue(value, mockEnvVars);
+ expect(result).toBe(value);
+ });
+
+ it('should resolve environment variables', async () => {
+ const result = await resolveValue(`${ENV_PREFIX}TEST_VAR`, mockEnvVars);
+ expect(result).toBe('test-value');
+ });
+
+ it('should handle nested environment variables', async () => {
+ const result = await resolveValue(`${ENV_PREFIX}NESTED_VAR`, mockEnvVars);
+ expect(result).toBe('test-value');
+ });
+
+ it('should handle non-existent environment variables', async () => {
+ const value = `${ENV_PREFIX}NONEXISTENT`;
+ const result = await resolveValue(value, mockEnvVars);
+ expect(result).toBe(value);
+ });
+
+ it('should return plain values unchanged', async () => {
+ const value = 'plain-value';
+ const result = await resolveValue(value, mockEnvVars);
+ expect(result).toBe(value);
+ });
+ });
+
+ describe('resolveInputs', () => {
+ it('should resolve multiple inputs', async () => {
+ const inputs = {
+ fragment: `${FRAGMENT_PREFIX}category/fragment`,
+ env: `${ENV_PREFIX}TEST_VAR`,
+ plain: 'plain-value'
+ };
+ mockReadEnvVars.mockResolvedValueOnce({
+ success: true,
+ data: [{ id: 1, name: 'TEST_VAR', value: 'test-value', scope: 'global' }]
+ });
+
+ mockViewFragmentContent.mockResolvedValueOnce({
+ success: true,
+ data: 'fragment-content'
+ });
+
+ const result = await resolveInputs(inputs);
+ expect(result).toEqual({
+ fragment: 'fragment-content',
+ env: 'test-value',
+ plain: 'plain-value'
+ });
+ });
+
+ it('should handle env vars fetch failure', async () => {
+ mockReadEnvVars.mockResolvedValueOnce({
+ success: false,
+ error: 'Failed to fetch env vars'
+ });
+
+ const inputs = {
+ plain: 'value'
+ };
+ const result = await resolveInputs(inputs);
+ expect(result).toEqual({
+ plain: 'value'
+ });
+ });
+
+ it('should handle resolution errors', async () => {
+ mockReadEnvVars.mockRejectedValueOnce(new Error('Failed to fetch env vars'));
+
+ const inputs = {
+ test: 'value'
+ };
+ await expect(resolveInputs(inputs)).rejects.toThrow();
+ });
+ });
+});
diff --git a/src/cli/utils/__tests__/prompts.test.ts b/src/cli/utils/__tests__/prompts.test.ts
new file mode 100644
index 0000000..26a2ef6
--- /dev/null
+++ b/src/cli/utils/__tests__/prompts.test.ts
@@ -0,0 +1,452 @@
+import { RunResult } from 'sqlite3';
+
+import { PromptMetadata, Variable } from '../../../shared/types';
+import { ENV_PREFIX, FRAGMENT_PREFIX } from '../../constants';
+import { allAsync, getAsync, runAsync } from '../database';
+import { readEnvVars } from '../env-vars';
+import { createPrompt, listPrompts, getPromptFiles, getPromptMetadata, viewPromptDetails } from '../prompts';
+
+jest.mock('../database');
+jest.mock('../env-vars');
+jest.mock('chalk', () => ({
+ cyan: jest.fn((text) => text),
+ green: jest.fn((text) => text),
+ blue: jest.fn((text) => text),
+ magenta: jest.fn((text) => text),
+ yellow: jest.fn((text) => text),
+ red: jest.fn((text) => text)
+}));
+jest.mock('../errors', () => ({
+ handleError: jest.fn()
+}));
+
+const mockMetadata: PromptMetadata = {
+ title: 'Test Prompt',
+ primary_category: 'test',
+ subcategories: ['sub1', 'sub2'],
+ directory: 'test-dir',
+ tags: ['tag1', 'tag2'],
+ one_line_description: 'Test description',
+ description: 'Full test description',
+ variables: [
+ { name: 'var1', role: 'test role', optional_for_user: false },
+ { name: 'var2', role: 'test role 2', optional_for_user: true }
+ ],
+ content_hash: 'test-hash',
+ fragments: [{ category: 'test', name: 'fragment1', variable: '{{var1}}' }]
+};
+describe('PromptsUtils', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ jest.spyOn(console, 'log').mockImplementation(() => {});
+ jest.spyOn(console, 'error').mockImplementation(() => {});
+ });
+
+ describe('createPrompt', () => {
+ it('should successfully create a prompt with all metadata', async () => {
+ const mockRunAsync = runAsync as jest.MockedFunction;
+ mockRunAsync.mockResolvedValueOnce({
+ success: true,
+ data: {
+ lastID: 1,
+ changes: 1
+ } as unknown as RunResult
+ });
+
+ mockRunAsync.mockResolvedValue({
+ success: true,
+ data: {
+ lastID: 1,
+ changes: 1
+ } as unknown as RunResult
+ });
+
+ const result = await createPrompt(mockMetadata, 'Test content');
+ expect(result.success).toBe(true);
+ expect(mockRunAsync).toHaveBeenCalledTimes(6);
+ expect(mockRunAsync).toHaveBeenCalledWith(
+ expect.stringContaining('INSERT INTO prompts'),
+ expect.arrayContaining([mockMetadata.title])
+ );
+ });
+
+ it('should handle database errors gracefully', async () => {
+ const mockRunAsync = runAsync as jest.MockedFunction;
+ mockRunAsync.mockResolvedValueOnce({ success: false, error: 'DB error' });
+
+ const result = await createPrompt(mockMetadata, 'Test content');
+ expect(result.success).toBe(false);
+ expect(result.error).toBe('Failed to insert prompt');
+ });
+
+ it('should handle exceptions during prompt creation', async () => {
+ const mockRunAsync = runAsync as jest.MockedFunction;
+ mockRunAsync.mockRejectedValueOnce(new Error('Database error'));
+
+ const result = await createPrompt(mockMetadata, 'Test content');
+ expect(result.success).toBe(false);
+ expect(result.error).toBe('Failed to create prompt');
+ });
+ });
+
+ describe('listPrompts', () => {
+ it('should return list of prompts successfully', async () => {
+ const mockPrompts = [
+ { id: 1, title: 'Prompt 1', primary_category: 'cat1' },
+ { id: 2, title: 'Prompt 2', primary_category: 'cat2' }
+ ];
+ const mockAllAsync = allAsync as jest.MockedFunction;
+ mockAllAsync.mockResolvedValueOnce({ success: true, data: mockPrompts });
+
+ const result = await listPrompts();
+ expect(result.success).toBe(true);
+ expect(result.data).toEqual(mockPrompts);
+ });
+
+ it('should handle empty results', async () => {
+ const mockAllAsync = allAsync as jest.MockedFunction;
+ mockAllAsync.mockResolvedValueOnce({ success: true, data: [] });
+
+ const result = await listPrompts();
+ expect(result.success).toBe(true);
+ expect(result.data).toEqual([]);
+ });
+
+ it('should handle errors', async () => {
+ const mockAllAsync = allAsync as jest.MockedFunction;
+ mockAllAsync.mockResolvedValueOnce({ success: false, error: 'DB error' });
+
+ const result = await listPrompts();
+ expect(result.success).toBe(false);
+ expect(result.error).toBe('Failed to list prompts');
+ });
+
+ it('should return error when listing prompts fails', async () => {
+ const mockAllAsync = allAsync as jest.MockedFunction;
+ mockAllAsync.mockRejectedValueOnce(new Error('Database error'));
+
+ const result = await listPrompts();
+ expect(result.success).toBe(false);
+ expect(result.error).toBe('Failed to list prompts');
+ });
+ });
+
+ describe('getPromptFiles', () => {
+ it('should return prompt content and metadata', async () => {
+ const mockGetAsync = getAsync as jest.MockedFunction;
+ const mockAllAsync = allAsync as jest.MockedFunction;
+ mockGetAsync.mockResolvedValueOnce({
+ success: true,
+ data: { content: 'Test content' }
+ });
+
+ mockGetAsync.mockResolvedValueOnce({
+ success: true,
+ data: {
+ id: 1,
+ title: 'Test Prompt',
+ primary_category: 'test',
+ directory: 'test-dir',
+ one_line_description: 'Test description',
+ description: 'Full test description',
+ content_hash: 'test-hash',
+ tags: 'tag1,tag2'
+ }
+ });
+
+ mockAllAsync.mockResolvedValueOnce({
+ success: true,
+ data: [{ name: 'sub1' }, { name: 'sub2' }]
+ });
+
+ mockAllAsync.mockResolvedValueOnce({
+ success: true,
+ data: [
+ { name: 'var1', role: 'test role', optional_for_user: false },
+ { name: 'var2', role: 'test role 2', optional_for_user: true }
+ ]
+ });
+
+ mockAllAsync.mockResolvedValueOnce({
+ success: true,
+ data: [{ category: 'test', name: 'fragment1', variable: 'var1' }]
+ });
+
+ const result = await getPromptFiles('1');
+ expect(result.success).toBe(true);
+ expect(result.data).toHaveProperty('promptContent', 'Test content');
+ expect(result.data).toHaveProperty('metadata');
+ });
+
+ it('should return error when prompt content is not found', async () => {
+ const mockGetAsync = getAsync as jest.MockedFunction;
+ mockGetAsync.mockResolvedValueOnce({
+ success: false,
+ error: 'Content not found'
+ });
+
+ const result = await getPromptFiles('1');
+ expect(result.success).toBe(false);
+ expect(result.error).toBe('Prompt not found');
+ });
+
+ it('should return error when metadata retrieval fails', async () => {
+ const mockGetAsync = getAsync as jest.MockedFunction;
+ mockGetAsync.mockResolvedValueOnce({
+ success: true,
+ data: { content: 'Test content' }
+ });
+
+ mockGetAsync.mockResolvedValueOnce({
+ success: false,
+ error: 'Metadata not found'
+ });
+
+ const result = await getPromptFiles('1');
+ expect(result.success).toBe(false);
+ expect(result.error).toBe('Failed to get prompt metadata');
+ });
+
+ it('should return error when prompt content data is undefined', async () => {
+ const mockGetAsync = getAsync as jest.MockedFunction;
+ mockGetAsync.mockResolvedValueOnce({
+ success: true,
+ data: undefined
+ });
+
+ const result = await getPromptFiles('1');
+ expect(result.success).toBe(false);
+ expect(result.error).toBe('Prompt not found');
+ });
+ });
+
+ describe('getPromptMetadata', () => {
+ it('should return error when prompt metadata is not found', async () => {
+ const mockGetAsync = getAsync as jest.MockedFunction;
+ mockGetAsync.mockResolvedValueOnce({
+ success: false,
+ error: 'Prompt not found'
+ });
+
+ const result = await getPromptMetadata('1');
+ expect(result.success).toBe(false);
+ expect(result.error).toBe('Metadata not found');
+ });
+
+ it('should return error when subcategories retrieval fails', async () => {
+ const mockGetAsync = getAsync as jest.MockedFunction;
+ const mockAllAsync = allAsync as jest.MockedFunction;
+ mockGetAsync.mockResolvedValueOnce({
+ success: true,
+ data: mockMetadata
+ });
+
+ mockAllAsync.mockResolvedValueOnce({
+ success: false,
+ error: 'Failed to get subcategories'
+ });
+
+ const result = await getPromptMetadata('1');
+ expect(result.success).toBe(false);
+ expect(result.error).toBe('Failed to get subcategories');
+ });
+
+ it('should return error when prompt metadata data is undefined', async () => {
+ const mockGetAsync = getAsync as jest.MockedFunction;
+ mockGetAsync.mockResolvedValueOnce({
+ success: true,
+ data: undefined
+ });
+
+ const result = await getPromptMetadata('1');
+ expect(result.success).toBe(false);
+ expect(result.error).toBe('Metadata not found');
+ });
+
+ it('should return error when subcategories data is undefined', async () => {
+ const mockGetAsync = getAsync as jest.MockedFunction;
+ const mockAllAsync = allAsync as jest.MockedFunction;
+ mockGetAsync.mockResolvedValueOnce({
+ success: true,
+ data: mockMetadata
+ });
+
+ mockAllAsync.mockResolvedValueOnce({
+ success: true,
+ data: undefined
+ });
+
+ const result = await getPromptMetadata('1');
+ expect(result.success).toBe(false);
+ expect(result.error).toBe('Failed to get subcategories');
+ });
+
+ it('should return error when variables data is undefined', async () => {
+ const mockGetAsync = getAsync as jest.MockedFunction;
+ const mockAllAsync = allAsync as jest.MockedFunction;
+ mockGetAsync.mockResolvedValueOnce({
+ success: true,
+ data: mockMetadata
+ });
+
+ mockAllAsync.mockResolvedValueOnce({
+ success: true,
+ data: [{ name: 'sub1' }, { name: 'sub2' }]
+ });
+
+ mockAllAsync.mockResolvedValueOnce({
+ success: true,
+ data: undefined
+ });
+
+ const result = await getPromptMetadata('1');
+ expect(result.success).toBe(false);
+ expect(result.error).toBe('Failed to get variables');
+ });
+
+ it('should return error when fragments data is undefined', async () => {
+ const mockGetAsync = getAsync as jest.MockedFunction;
+ const mockAllAsync = allAsync as jest.MockedFunction;
+ mockGetAsync.mockResolvedValueOnce({
+ success: true,
+ data: mockMetadata
+ });
+
+ mockAllAsync.mockResolvedValueOnce({
+ success: true,
+ data: [{ name: 'sub1' }, { name: 'sub2' }]
+ });
+
+ mockAllAsync.mockResolvedValueOnce({
+ success: true,
+ data: [
+ { name: 'var1', role: 'test role', optional_for_user: false },
+ { name: 'var2', role: 'test role 2', optional_for_user: true }
+ ]
+ });
+
+ mockAllAsync.mockResolvedValueOnce({
+ success: true,
+ data: undefined
+ });
+
+ const result = await getPromptMetadata('1');
+ expect(result.success).toBe(false);
+ expect(result.error).toBe('Failed to get fragments');
+ });
+ });
+
+ describe('viewPromptDetails', () => {
+ const mockPrompt: PromptMetadata = {
+ id: '1',
+ title: 'Test Prompt',
+ primary_category: 'test',
+ subcategories: ['sub1', 'sub2'],
+ directory: 'test-dir',
+ tags: ['tag1', 'tag2'],
+ one_line_description: 'Test description',
+ description: 'Full test description',
+ variables: [
+ { name: 'var1', role: 'test role', optional_for_user: false },
+ { name: 'var2', role: 'test role 2', optional_for_user: true }
+ ],
+ content_hash: 'test-hash',
+ fragments: [{ category: 'test', name: 'fragment1', variable: 'var1' }]
+ };
+ let consoleOutput: string[];
+ beforeEach(() => {
+ jest.clearAllMocks();
+ consoleOutput = [];
+ jest.spyOn(console, 'log').mockImplementation((...args) => {
+ consoleOutput.push(args.join(' '));
+ });
+ });
+
+ it('should display prompt details correctly', async () => {
+ const mockReadEnvVars = readEnvVars as jest.MockedFunction;
+ mockReadEnvVars.mockResolvedValueOnce({
+ success: true,
+ data: [{ id: 1, name: 'var1', value: 'test-value', scope: 'global' }]
+ });
+
+ await viewPromptDetails(mockPrompt);
+
+ expect(consoleOutput.join('\n')).toMatchSnapshot();
+ });
+
+ it('should handle env vars fetch failure', async () => {
+ const mockReadEnvVars = readEnvVars as jest.MockedFunction;
+ mockReadEnvVars.mockResolvedValueOnce({ success: false, error: 'Failed to read env vars' });
+
+ await viewPromptDetails(mockPrompt);
+
+ expect(consoleOutput.join('\n')).toMatchSnapshot();
+ });
+
+ it('should display fragment variable correctly', async () => {
+ const mockPromptWithFragment: PromptMetadata & { variables: Variable[] } = {
+ ...mockPrompt,
+ variables: [
+ {
+ name: 'var1',
+ role: 'test role',
+ optional_for_user: false,
+ value: `${FRAGMENT_PREFIX}fragmentName`
+ }
+ ]
+ };
+ await viewPromptDetails(mockPromptWithFragment);
+
+ expect(consoleOutput.join('\n')).toMatchSnapshot();
+ });
+
+ it('should display env variable correctly', async () => {
+ const mockPromptWithEnvVar: PromptMetadata & { variables: Variable[] } = {
+ ...mockPrompt,
+ variables: [
+ {
+ name: 'var1',
+ role: 'test role',
+ optional_for_user: false,
+ value: `${ENV_PREFIX}ENV_VAR_NAME`
+ }
+ ]
+ };
+ const mockReadEnvVars = readEnvVars as jest.MockedFunction;
+ mockReadEnvVars.mockResolvedValueOnce({
+ success: true,
+ data: [{ id: 1, name: 'ENV_VAR_NAME', value: 'env-value', scope: 'global' }]
+ });
+
+ await viewPromptDetails(mockPromptWithEnvVar);
+
+ expect(consoleOutput.join('\n')).toMatchSnapshot();
+ });
+
+ it('should display regular variable value correctly', async () => {
+ const mockPromptWithValue: PromptMetadata & { variables: Variable[] } = {
+ ...mockPrompt,
+ variables: [
+ {
+ name: 'var1',
+ role: 'test role',
+ optional_for_user: false,
+ value: 'regular value'
+ }
+ ]
+ };
+ await viewPromptDetails(mockPromptWithValue);
+
+ expect(consoleOutput.join('\n')).toMatchSnapshot();
+ });
+
+ it('should not display variable status when isExecute is true', async () => {
+ await viewPromptDetails(mockPrompt, true);
+
+ const output = consoleOutput.join('\n');
+ expect(output).not.toContain('Not Set');
+ expect(output).not.toContain('Set:');
+ });
+ });
+});
diff --git a/src/cli/utils/conversation_manager.util.ts b/src/cli/utils/conversation-manager.ts
similarity index 92%
rename from src/cli/utils/conversation_manager.util.ts
rename to src/cli/utils/conversation-manager.ts
index 8daaf7d..b62b641 100644
--- a/src/cli/utils/conversation_manager.util.ts
+++ b/src/cli/utils/conversation-manager.ts
@@ -1,8 +1,8 @@
-import { handleError } from './error.util';
-import { resolveInputs } from './input_resolution.util';
-import { getPromptFiles } from './prompt_crud.util';
+import { handleError } from './errors';
+import { resolveInputs } from './input-resolver';
+import { getPromptFiles } from './prompts';
import { ApiResult } from '../../shared/types';
-import { processPromptContent, updatePromptWithVariables } from '../../shared/utils/prompt_processing.util';
+import { processPromptContent, updatePromptWithVariables } from '../../shared/utils/prompt-processing';
interface ConversationMessage {
role: 'user' | 'assistant';
diff --git a/src/cli/utils/database.util.ts b/src/cli/utils/database.ts
similarity index 92%
rename from src/cli/utils/database.util.ts
rename to src/cli/utils/database.ts
index 45e2fbf..3c93a40 100644
--- a/src/cli/utils/database.util.ts
+++ b/src/cli/utils/database.ts
@@ -5,16 +5,17 @@ import yaml from 'js-yaml';
import NodeCache from 'node-cache';
import sqlite3, { RunResult } from 'sqlite3';
-import { AppError, handleError } from './error.util';
-import { createPrompt } from './prompt_crud.util';
-import { commonConfig } from '../../shared/config/common.config';
-import { ApiResult, CategoryItem, Metadata, Prompt, Variable } from '../../shared/types';
-import { fileExists, readDirectory, readFileContent } from '../../shared/utils/file_system.util';
-import logger from '../../shared/utils/logger.util';
-import { cliConfig } from '../cli.config';
+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 { fileExists, readDirectory, readFileContent } from '../../shared/utils/file-system';
+import logger from '../../shared/utils/logger';
+import { cliConfig } from '../config/cli-config';
const db = new sqlite3.Database(cliConfig.DB_PATH);
-const cache = new NodeCache({ stdTTL: 600 });
+
+export const cache = new NodeCache({ stdTTL: 600 });
export async function runAsync(sql: string, params: any[] = []): Promise> {
return new Promise((resolve) => {
@@ -65,17 +66,21 @@ export async function handleApiResult(result: ApiResult, message: string):
}
}
-export async function getCachedOrFetch(key: string, fetchFn: () => Promise>): Promise> {
- const cachedResult = cache.get(key);
+export async function getCachedOrFetch(
+ key: string,
+ fetchFn: () => Promise>,
+ cacheInstance: NodeCache = cache
+): Promise> {
+ const cachedResult = cacheInstance.get(key);
- if (cachedResult) {
+ if (cachedResult !== undefined) {
return { success: true, data: cachedResult };
}
const result = await fetchFn();
if (result.success && result.data) {
- cache.set(key, result.data);
+ cacheInstance.set(key, result.data);
}
return result;
}
@@ -177,8 +182,10 @@ export async function fetchCategories(): Promise> {
- const promptResult = await getAsync('SELECT * FROM prompts WHERE id = ?', [promptId]);
+export async function getPromptDetails(
+ promptId: string
+): Promise> {
+ const promptResult = await getAsync('SELECT * FROM prompts WHERE id = ?', [promptId]);
const variablesResult = await allAsync(
'SELECT name, role, value, optional_for_user FROM variables WHERE prompt_id = ?',
[promptId]
@@ -189,10 +196,7 @@ export async function getPromptDetails(promptId: string): Promise tag.trim())
- : promptResult.data.tags || [];
+ promptResult.data.tags = promptResult.data.tags || [];
}
return {
success: true,
@@ -253,7 +257,7 @@ export async function syncPromptsWithDatabase(): Promise> {
const metadataContent = await readFileContent(metadataPath);
try {
- const metadata = yaml.load(metadataContent) as Metadata;
+ const metadata = yaml.load(metadataContent) as PromptMetadata;
await createPrompt(metadata, promptContent);
logger.info(`Successfully processed prompt: ${dir}`);
} catch (error) {
diff --git a/src/cli/utils/env.util.ts b/src/cli/utils/env-vars.ts
similarity index 96%
rename from src/cli/utils/env.util.ts
rename to src/cli/utils/env-vars.ts
index 3898294..529f2c3 100644
--- a/src/cli/utils/env.util.ts
+++ b/src/cli/utils/env-vars.ts
@@ -1,5 +1,5 @@
-import { runAsync, allAsync } from './database.util';
-import { handleError } from './error.util';
+import { runAsync, allAsync } from './database';
+import { handleError } from './errors';
import { EnvVar, ApiResult } from '../../shared/types';
export async function createEnvVar(envVar: Omit): Promise> {
diff --git a/src/cli/utils/error.util.ts b/src/cli/utils/errors.ts
similarity index 96%
rename from src/cli/utils/error.util.ts
rename to src/cli/utils/errors.ts
index c3240d4..f535ad3 100644
--- a/src/cli/utils/error.util.ts
+++ b/src/cli/utils/errors.ts
@@ -1,6 +1,6 @@
import chalk from 'chalk';
-import logger from '../../shared/utils/logger.util';
+import logger from '../../shared/utils/logger';
export class AppError extends Error {
constructor(
diff --git a/src/cli/utils/file_system.util.ts b/src/cli/utils/file-system.ts
similarity index 82%
rename from src/cli/utils/file_system.util.ts
rename to src/cli/utils/file-system.ts
index 0513eda..c2a6bbe 100644
--- a/src/cli/utils/file_system.util.ts
+++ b/src/cli/utils/file-system.ts
@@ -1,8 +1,8 @@
import fs from 'fs-extra';
-import { handleError } from './error.util';
-import { readDirectory } from '../../shared/utils/file_system.util';
-import { cliConfig } from '../cli.config';
+import { handleError } from './errors';
+import { readDirectory } from '../../shared/utils/file-system';
+import { cliConfig } from '../config/cli-config';
export async function hasPrompts(): Promise {
try {
diff --git a/src/cli/utils/fragment_operations.util.ts b/src/cli/utils/fragments.ts
similarity index 93%
rename from src/cli/utils/fragment_operations.util.ts
rename to src/cli/utils/fragments.ts
index 5b6bdc2..c991278 100644
--- a/src/cli/utils/fragment_operations.util.ts
+++ b/src/cli/utils/fragments.ts
@@ -1,9 +1,9 @@
import path from 'path';
-import { handleError } from './error.util';
+import { handleError } from './errors';
import { ApiResult, Fragment } from '../../shared/types';
-import { readDirectory, readFileContent } from '../../shared/utils/file_system.util';
-import { cliConfig } from '../cli.config';
+import { readDirectory, readFileContent } from '../../shared/utils/file-system';
+import { cliConfig } from '../config/cli-config';
export async function listFragments(): Promise> {
try {
diff --git a/src/cli/utils/input_resolution.util.ts b/src/cli/utils/input-resolver.ts
similarity index 77%
rename from src/cli/utils/input_resolution.util.ts
rename to src/cli/utils/input-resolver.ts
index f4aeb43..cd881a7 100644
--- a/src/cli/utils/input_resolution.util.ts
+++ b/src/cli/utils/input-resolver.ts
@@ -1,9 +1,9 @@
import { EnvVar } from '../../shared/types';
-import logger from '../../shared/utils/logger.util';
-import { FRAGMENT_PREFIX, ENV_PREFIX } from '../cli.constants';
-import { readEnvVars } from './env.util';
-import { handleError } from './error.util';
-import { viewFragmentContent } from './fragment_operations.util';
+import logger from '../../shared/utils/logger';
+import { FRAGMENT_PREFIX, ENV_PREFIX } from '../constants';
+import { readEnvVars } from './env-vars';
+import { handleError } from './errors';
+import { viewFragmentContent } from './fragments';
export async function resolveValue(value: string, envVars: EnvVar[]): Promise {
if (value.startsWith(FRAGMENT_PREFIX)) {
@@ -21,7 +21,12 @@ export async function resolveValue(value: string, envVars: EnvVar[]): Promise v.name === envVarName);
if (actualEnvVar) {
- return await resolveValue(actualEnvVar.value, envVars);
+ const envVarValue = actualEnvVar.value;
+
+ if (envVarValue.startsWith('$env:')) {
+ return await resolveValue(`${ENV_PREFIX}${envVarValue.slice(5)}`, envVars);
+ }
+ return envVarValue;
} else {
logger.warn(`Env var not found: ${envVarName}`);
return value;
diff --git a/src/cli/utils/metadata.util.ts b/src/cli/utils/metadata.util.ts
deleted file mode 100644
index f5f1d0d..0000000
--- a/src/cli/utils/metadata.util.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-import { getAsync, allAsync } from './database.util';
-import { handleError } from './error.util';
-import { ApiResult, Fragment, Metadata } from '../../shared/types';
-
-export async function getPromptMetadata(promptId: string): Promise> {
- try {
- const promptResult = await getAsync<{
- id: number;
- title: string;
- primary_category: string;
- directory: string;
- one_line_description: string;
- description: string;
- content_hash: string;
- tags: string;
- }>('SELECT * FROM prompts WHERE id = ?', [promptId]);
-
- if (!promptResult.success || !promptResult.data) {
- return { success: false, error: 'Prompt not found' };
- }
-
- const prompt = promptResult.data;
- const subcategoriesResult = await allAsync<{ name: string }>(
- 'SELECT name FROM subcategories WHERE prompt_id = ?',
- [promptId]
- );
- const variablesResult = await allAsync<{ name: string; role: string; optional_for_user: boolean }>(
- 'SELECT name, role, optional_for_user FROM variables WHERE prompt_id = ?',
- [promptId]
- );
- const fragmentsResult = await allAsync(
- 'SELECT category, name, variable FROM fragments WHERE prompt_id = ?',
- [promptId]
- );
- const metadata: Metadata = {
- title: prompt.title,
- primary_category: prompt.primary_category,
- subcategories: subcategoriesResult.success ? (subcategoriesResult.data?.map((s) => s.name) ?? []) : [],
- directory: prompt.directory,
- tags: prompt.tags ? prompt.tags.split(',') : [],
- one_line_description: prompt.one_line_description,
- description: prompt.description,
- variables: variablesResult.success ? (variablesResult.data ?? []) : [],
- content_hash: prompt.content_hash,
- fragments: fragmentsResult.success ? (fragmentsResult.data ?? []) : []
- };
- return { success: true, data: metadata };
- } catch (error) {
- handleError(error, 'getting prompt metadata');
- return { success: false, error: 'Failed to get prompt metadata' };
- }
-}
diff --git a/src/cli/utils/prompt_crud.util.ts b/src/cli/utils/prompt_crud.util.ts
deleted file mode 100644
index 7450745..0000000
--- a/src/cli/utils/prompt_crud.util.ts
+++ /dev/null
@@ -1,93 +0,0 @@
-import { allAsync, getAsync, runAsync } from './database.util';
-import { handleError } from './error.util';
-import { getPromptMetadata } from './metadata.util';
-import { ApiResult, Metadata, Prompt } from '../../shared/types';
-
-export async function createPrompt(metadata: Metadata, content: string): Promise> {
- try {
- const result = await runAsync(
- 'INSERT INTO prompts (title, content, primary_category, directory, one_line_description, description, content_hash, tags) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
- [
- metadata.title,
- content,
- metadata.primary_category,
- metadata.directory,
- metadata.one_line_description,
- metadata.description,
- metadata.content_hash,
- metadata.tags.join(',')
- ]
- );
- const promptId = result.data?.lastID;
-
- if (!promptId) {
- return { success: false, error: 'Failed to insert prompt' };
- }
-
- for (const subcategory of metadata.subcategories) {
- await runAsync('INSERT INTO subcategories (prompt_id, name) VALUES (?, ?)', [promptId, subcategory]);
- }
-
- for (const variable of metadata.variables) {
- await runAsync('INSERT INTO variables (prompt_id, name, role, optional_for_user) VALUES (?, ?, ?, ?)', [
- promptId,
- variable.name,
- variable.role,
- variable.optional_for_user
- ]);
- }
-
- for (const fragment of metadata.fragments || []) {
- await runAsync('INSERT INTO fragments (prompt_id, category, name, variable) VALUES (?, ?, ?, ?)', [
- promptId,
- fragment.category,
- fragment.name,
- fragment.variable
- ]);
- }
- return { success: true };
- } catch (error) {
- handleError(error, 'creating prompt');
- return { success: false, error: 'Failed to create prompt' };
- }
-}
-
-export async function listPrompts(): Promise> {
- try {
- const prompts = await allAsync('SELECT id, title, primary_category FROM prompts');
- return { success: true, data: prompts.data ?? [] };
- } catch (error) {
- handleError(error, 'listing prompts');
- return { success: false, error: 'Failed to list prompts' };
- }
-}
-
-export async function getPromptFiles(
- promptId: string
-): Promise> {
- try {
- const promptContentResult = await getAsync<{ content: string }>('SELECT content FROM prompts WHERE id = ?', [
- promptId
- ]);
-
- if (!promptContentResult.success || !promptContentResult.data) {
- return { success: false, error: 'Prompt not found' };
- }
-
- const metadataResult = await getPromptMetadata(promptId);
-
- if (!metadataResult.success || !metadataResult.data) {
- return { success: false, error: 'Failed to get prompt metadata' };
- }
- return {
- success: true,
- data: {
- promptContent: promptContentResult.data.content,
- metadata: metadataResult.data
- }
- };
- } catch (error) {
- handleError(error, 'getting prompt files');
- return { success: false, error: 'Failed to get prompt files' };
- }
-}
diff --git a/src/cli/utils/prompt_display.util.ts b/src/cli/utils/prompt_display.util.ts
deleted file mode 100644
index b2a8f77..0000000
--- a/src/cli/utils/prompt_display.util.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-import chalk from 'chalk';
-
-import { Prompt, Variable } from '../../shared/types';
-import { formatTitleCase, formatSnakeCase } from '../../shared/utils/string_formatter.util';
-import { FRAGMENT_PREFIX, ENV_PREFIX } from '../cli.constants';
-import { readEnvVars } from './env.util';
-import { handleError } from './error.util';
-
-export async function viewPromptDetails(details: Prompt & { variables: Variable[] }, isExecute = false): Promise {
- // console.clear();
- console.log(chalk.cyan('Prompt:'), details.title);
- console.log(`\n${details.description || ''}`);
- console.log(chalk.cyan('\nCategory:'), formatTitleCase(details.primary_category));
-
- let tags: string[] = [];
-
- if (typeof details.tags === 'string') {
- tags = details.tags.split(',').map((tag) => tag.trim());
- } else if (Array.isArray(details.tags)) {
- tags = details.tags;
- }
-
- console.log(chalk.cyan('\nTags:'), tags.length > 0 ? tags.join(', ') : 'No tags');
- console.log(chalk.cyan('\nOptions:'), '([*] Required [ ] Optional)');
- const maxNameLength = Math.max(...details.variables.map((v) => formatSnakeCase(v.name).length));
-
- try {
- const envVarsResult = await readEnvVars();
- const envVars = envVarsResult.success ? envVarsResult.data || [] : [];
-
- for (const variable of details.variables) {
- const paddedName = formatSnakeCase(variable.name).padEnd(maxNameLength);
- const requiredFlag = variable.optional_for_user ? '[ ]' : '[*]';
- const matchingEnvVar = envVars.find((v) => v.name === variable.name);
- let status;
-
- if (variable.value) {
- if (variable.value.startsWith(FRAGMENT_PREFIX)) {
- status = chalk.blue(variable.value);
- } else if (variable.value.startsWith(ENV_PREFIX)) {
- const envVarName = variable.value.split(ENV_PREFIX)[1];
- const envVar = envVars.find((v: { name: string }) => v.name === envVarName);
- const envValue = envVar ? envVar.value : 'Not found';
- status = chalk.magenta(
- `${ENV_PREFIX}${formatSnakeCase(envVarName)} (${envValue.substring(0, 30)}${envValue.length > 30 ? '...' : ''})`
- );
- } else {
- status = chalk.green(
- `Set: ${variable.value.substring(0, 30)}${variable.value.length > 30 ? '...' : ''}`
- );
- }
- } else {
- status = variable.optional_for_user ? chalk.yellow('Not Set') : chalk.red('Not Set (Required)');
- }
-
- const hint =
- !isExecute &&
- matchingEnvVar &&
- (!variable.value || (variable.value && !variable.value.startsWith(ENV_PREFIX)))
- ? chalk.magenta('(Env variable available)')
- : '';
- console.log(` ${chalk.green(`--${paddedName}`)} ${requiredFlag} ${hint}`);
- console.log(` ${variable.role}`);
-
- if (!isExecute) {
- console.log(` ${status}`);
- }
- }
-
- if (!isExecute) {
- console.log();
- }
- } catch (error) {
- handleError(error, 'viewing prompt details');
- }
-}
diff --git a/src/cli/utils/prompts.ts b/src/cli/utils/prompts.ts
new file mode 100644
index 0000000..cc9ddf4
--- /dev/null
+++ b/src/cli/utils/prompts.ts
@@ -0,0 +1,235 @@
+import chalk from 'chalk';
+
+import { allAsync, getAsync, runAsync } from './database';
+import { handleError } from './errors';
+import { ApiResult, Fragment, PromptMetadata, Variable } from '../../shared/types';
+import { formatSnakeCase, formatTitleCase } from '../../shared/utils/string-formatter';
+import { FRAGMENT_PREFIX, ENV_PREFIX } from '../constants';
+import { readEnvVars } from './env-vars';
+
+export async function createPrompt(promptMetadata: PromptMetadata, content: string): Promise> {
+ try {
+ let tagsString: string;
+
+ if (typeof promptMetadata.tags === 'string') {
+ tagsString = promptMetadata.tags;
+ } else {
+ tagsString = promptMetadata.tags.join(',');
+ }
+
+ const result = await runAsync(
+ 'INSERT INTO prompts (title, content, primary_category, directory, one_line_description, description, content_hash, tags) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
+ [
+ promptMetadata.title,
+ content,
+ promptMetadata.primary_category,
+ promptMetadata.directory,
+ promptMetadata.one_line_description,
+ promptMetadata.description,
+ promptMetadata.content_hash,
+ tagsString
+ ]
+ );
+ const promptId = result.data?.lastID;
+
+ if (!promptId) {
+ return { success: false, error: 'Failed to insert prompt' };
+ }
+
+ for (const subcategory of promptMetadata.subcategories) {
+ await runAsync('INSERT INTO subcategories (prompt_id, name) VALUES (?, ?)', [promptId, subcategory]);
+ }
+
+ for (const variable of promptMetadata.variables) {
+ await runAsync('INSERT INTO variables (prompt_id, name, role, optional_for_user) VALUES (?, ?, ?, ?)', [
+ promptId,
+ variable.name,
+ variable.role,
+ variable.optional_for_user
+ ]);
+ }
+
+ for (const fragment of promptMetadata.fragments || []) {
+ await runAsync('INSERT INTO fragments (prompt_id, category, name) VALUES (?, ?, ?)', [
+ promptId,
+ fragment.category,
+ fragment.name
+ ]);
+ }
+ return { success: true };
+ } catch (error) {
+ handleError(error, 'creating prompt');
+ return { success: false, error: 'Failed to create prompt' };
+ }
+}
+
+export async function listPrompts(): Promise> {
+ try {
+ const prompts = await allAsync('SELECT id, title, primary_category FROM prompts');
+
+ if (!prompts.success || !prompts.data) {
+ return { success: false, error: 'Failed to list prompts' };
+ }
+ return { success: true, data: prompts.data };
+ } catch (error) {
+ handleError(error, 'listing prompts');
+ return { success: false, error: 'Failed to list prompts' };
+ }
+}
+
+export async function getPromptFiles(
+ promptId: string
+): Promise> {
+ try {
+ const promptContentResult = await getAsync<{ content: string }>('SELECT content FROM prompts WHERE id = ?', [
+ promptId
+ ]);
+
+ if (!promptContentResult.success || !promptContentResult.data) {
+ return { success: false, error: 'Prompt not found' };
+ }
+
+ const metadataResult = await getPromptMetadata(promptId);
+
+ if (!metadataResult.success || !metadataResult.data) {
+ return { success: false, error: 'Failed to get prompt metadata' };
+ }
+ return {
+ success: true,
+ data: {
+ promptContent: promptContentResult.data.content,
+ metadata: metadataResult.data
+ }
+ };
+ } catch (error) {
+ handleError(error, 'getting prompt files');
+ return { success: false, error: 'Failed to get prompt files' };
+ }
+}
+
+export async function getPromptMetadata(promptId: string): Promise> {
+ try {
+ const promptResult = await getAsync('SELECT * FROM prompts WHERE id = ?', [promptId]);
+
+ if (!promptResult.success || !promptResult.data) {
+ return { success: false, error: 'Metadata not found' };
+ }
+
+ const subcategoriesResult = await allAsync<{ name: string }>(
+ 'SELECT name FROM subcategories WHERE prompt_id = ?',
+ [promptId]
+ );
+
+ if (!subcategoriesResult.success || !subcategoriesResult.data) {
+ return { success: false, error: 'Failed to get subcategories' };
+ }
+
+ const variablesResult = await allAsync(
+ 'SELECT name, role, optional_for_user, value FROM variables WHERE prompt_id = ?',
+ [promptId]
+ );
+
+ if (!variablesResult.success || !variablesResult.data) {
+ return { success: false, error: 'Failed to get variables' };
+ }
+
+ const fragmentsResult = await allAsync(
+ 'SELECT category, name, variable FROM fragments WHERE prompt_id = ?',
+ [promptId]
+ );
+
+ if (!fragmentsResult.success || !fragmentsResult.data) {
+ return { success: false, error: 'Failed to get fragments' };
+ }
+
+ const promptMetadata: PromptMetadata = {
+ id: promptResult.data.id,
+ title: promptResult.data.title,
+ primary_category: promptResult.data.primary_category,
+ subcategories: subcategoriesResult.data.map((s) => s.name),
+ directory: promptResult.data.directory,
+ tags: promptResult.data.tags,
+ one_line_description: promptResult.data.one_line_description,
+ description: promptResult.data.description,
+ variables: variablesResult.data,
+ content_hash: promptResult.data.content_hash,
+ fragments: fragmentsResult.data
+ };
+ return { success: true, data: promptMetadata };
+ } catch (error) {
+ handleError(error, 'getting prompt metadata');
+ return { success: false, error: 'Failed to get prompt metadata' };
+ }
+}
+
+export async function viewPromptDetails(
+ details: PromptMetadata & { variables: Variable[] },
+ isExecute = false
+): Promise {
+ console.error(details);
+
+ console.log(chalk.cyan('Prompt:'), details.title);
+ console.log(`\n${details.description || ''}`);
+ console.log(chalk.cyan('\nCategory:'), formatTitleCase(details.primary_category));
+ let tagsArray: string[];
+
+ if (typeof details.tags === 'string') {
+ tagsArray = details.tags.split(',');
+ } else {
+ tagsArray = details.tags;
+ }
+
+ console.log(chalk.cyan('\nTags:'), tagsArray.length > 0 ? tagsArray.join(', ') : 'No tags');
+ console.log(chalk.cyan('\nOptions:'), '([*] Required [ ] Optional)');
+ const maxNameLength = Math.max(...details.variables.map((v) => formatSnakeCase(v.name).length));
+
+ try {
+ const envVarsResult = await readEnvVars();
+ const envVars = envVarsResult.success ? envVarsResult.data || [] : [];
+
+ for (const variable of details.variables) {
+ const paddedName = formatSnakeCase(variable.name).padEnd(maxNameLength);
+ const requiredFlag = variable.optional_for_user ? '[ ]' : '[*]';
+ const matchingEnvVar = envVars.find((v) => v.name === variable.name);
+ let status;
+
+ if (variable.value) {
+ if (variable.value.startsWith(FRAGMENT_PREFIX)) {
+ status = chalk.blue(variable.value);
+ } else if (variable.value.startsWith(ENV_PREFIX)) {
+ const envVarName = variable.value.split(ENV_PREFIX)[1];
+ const envVar = envVars.find((v: { name: string }) => v.name === envVarName);
+ const envValue = envVar ? envVar.value : 'Not found';
+ status = chalk.magenta(
+ `${ENV_PREFIX}${formatSnakeCase(envVarName)} (${envValue.substring(0, 30)}${envValue.length > 30 ? '...' : ''})`
+ );
+ } else {
+ status = chalk.green(
+ `Set: ${variable.value.substring(0, 30)}${variable.value.length > 30 ? '...' : ''}`
+ );
+ }
+ } else {
+ status = variable.optional_for_user ? chalk.yellow('Not Set') : chalk.red('Not Set (Required)');
+ }
+
+ const hint =
+ !isExecute &&
+ matchingEnvVar &&
+ (!variable.value || (variable.value && !variable.value.startsWith(ENV_PREFIX)))
+ ? chalk.magenta('(Env variable available)')
+ : '';
+ console.log(` ${chalk.green(`--${paddedName}`)} ${requiredFlag} ${hint}`);
+ console.log(` ${variable.role}`);
+
+ if (!isExecute) {
+ console.log(` ${status}`);
+ }
+ }
+
+ if (!isExecute) {
+ console.log();
+ }
+ } catch (error) {
+ handleError(error, 'viewing prompt details');
+ }
+}
diff --git a/src/shared/config/__tests__/config.test.ts b/src/shared/config/__tests__/config.test.ts
new file mode 100644
index 0000000..de0afb2
--- /dev/null
+++ b/src/shared/config/__tests__/config.test.ts
@@ -0,0 +1,199 @@
+/* eslint-disable @typescript-eslint/no-require-imports */
+import * as path from 'path';
+
+describe('Config', () => {
+ let originalEnv: NodeJS.ProcessEnv;
+ beforeAll(() => {
+ originalEnv = { ...process.env };
+ });
+
+ beforeEach(() => {
+ process.env = { ...originalEnv };
+ process.env.CLI_ENV = 'cli';
+
+ jest.resetModules();
+
+ jest.mock('fs', () => ({
+ existsSync: jest.fn(() => false),
+ readFileSync: jest.fn(() => '{}'),
+ writeFileSync: jest.fn(),
+ mkdirSync: jest.fn()
+ }));
+
+ jest.mock('../constants', () => ({
+ get isCliEnvironment(): boolean {
+ return process.env.CLI_ENV === 'cli';
+ },
+ CONFIG_DIR: '/mock/config/dir',
+ CONFIG_FILE: '/mock/config/dir/config.json'
+ }));
+
+ jest.mock('../common-config', () => ({
+ commonConfig: {
+ ANTHROPIC_API_KEY: undefined,
+ ANTHROPIC_MODEL: 'claude-3-5-sonnet-20240620',
+ ANTHROPIC_MAX_TOKENS: 8000,
+ PROMPT_FILE_NAME: 'prompt.md',
+ METADATA_FILE_NAME: 'metadata.yml',
+ LOG_LEVEL: 'info',
+ REMOTE_REPOSITORY: '',
+ CLI_ENV: 'cli'
+ }
+ }));
+
+ jest.mock('../../../cli/config/cli-config', () => {
+ const mockConfigDir = '/mock/config/dir';
+ return {
+ cliConfig: {
+ PROMPTS_DIR: 'prompts',
+ FRAGMENTS_DIR: 'fragments',
+ DB_PATH: path.join(mockConfigDir, 'prompts.sqlite'),
+ TEMP_DIR: path.join(mockConfigDir, 'temp'),
+ MENU_PAGE_SIZE: 20
+ }
+ };
+ });
+ });
+
+ afterEach(() => {
+ process.env = { ...originalEnv };
+ });
+
+ describe('getConfig', () => {
+ it('should return default config when no file exists', () => {
+ process.env.CLI_ENV = 'cli';
+
+ const mockFs = require('fs');
+ mockFs.existsSync.mockReturnValue(false);
+ mockFs.readFileSync.mockReturnValue('{}');
+
+ const { getConfig } = require('../../config');
+ const config = getConfig();
+ expect(config.ANTHROPIC_API_KEY).toBeUndefined();
+ expect(config).toMatchObject({
+ ANTHROPIC_MAX_TOKENS: 8000,
+ ANTHROPIC_MODEL: 'claude-3-5-sonnet-20240620',
+ CLI_ENV: 'cli'
+ });
+ });
+
+ it('should merge file config with default config', () => {
+ process.env.CLI_ENV = 'cli';
+
+ const mockFs = require('fs');
+ mockFs.existsSync.mockReturnValue(true);
+ mockFs.readFileSync.mockReturnValue(
+ JSON.stringify({
+ ANTHROPIC_API_KEY: 'file-key'
+ })
+ );
+
+ const { getConfig } = require('../../config');
+ const config = getConfig();
+ expect(config.ANTHROPIC_API_KEY).toBe('file-key');
+ });
+ });
+
+ describe('getConfigValue', () => {
+ it('should prefer process.env value in non-CLI environment', () => {
+ process.env.CLI_ENV = 'app';
+ process.env.ANTHROPIC_API_KEY = 'env-key';
+
+ const mockFs = require('fs');
+ mockFs.readFileSync.mockReturnValue(
+ JSON.stringify({
+ ANTHROPIC_API_KEY: 'file-key'
+ })
+ );
+
+ const { getConfigValue, clearConfigCache } = require('../../config');
+ clearConfigCache();
+
+ const value = getConfigValue('ANTHROPIC_API_KEY');
+ expect(value).toBe('env-key');
+ });
+
+ it('should use file value in CLI environment even if env var exists', () => {
+ process.env.CLI_ENV = 'cli';
+ process.env.ANTHROPIC_API_KEY = 'env-key';
+
+ const mockFs = require('fs');
+ mockFs.existsSync.mockReturnValue(true);
+ mockFs.readFileSync.mockReturnValue(
+ JSON.stringify({
+ ANTHROPIC_API_KEY: 'file-key'
+ })
+ );
+
+ const { getConfigValue, clearConfigCache } = require('../../config');
+ clearConfigCache();
+
+ const value = getConfigValue('ANTHROPIC_API_KEY');
+ expect(value).toBe('file-key');
+ });
+
+ it('should return updated value after setConfig', () => {
+ process.env.CLI_ENV = 'cli';
+
+ const mockFs = require('fs');
+ mockFs.existsSync.mockReturnValue(true);
+ mockFs.readFileSync.mockReturnValue(
+ JSON.stringify({
+ ANTHROPIC_API_KEY: 'initial-key'
+ })
+ );
+
+ const { setConfig, getConfigValue, clearConfigCache } = require('../../config');
+ clearConfigCache();
+
+ expect(getConfigValue('ANTHROPIC_API_KEY')).toBe('initial-key');
+
+ setConfig('ANTHROPIC_API_KEY', 'updated-key');
+
+ expect(getConfigValue('ANTHROPIC_API_KEY')).toBe('updated-key');
+ expect(process.env.ANTHROPIC_API_KEY).toBe('updated-key');
+ });
+ });
+
+ describe('setConfig', () => {
+ it('should throw error if not in CLI environment', () => {
+ process.env.CLI_ENV = 'app';
+
+ const { setConfig } = require('../../config');
+ expect(() => setConfig('ANTHROPIC_API_KEY', 'new-key')).toThrow();
+ });
+
+ it('should update config file and process.env', () => {
+ process.env.CLI_ENV = 'cli';
+
+ const mockFs = require('fs');
+ mockFs.existsSync.mockReturnValue(true);
+
+ const { setConfig } = require('../../config');
+ setConfig('ANTHROPIC_API_KEY', 'new-key');
+
+ expect(mockFs.writeFileSync).toHaveBeenCalledWith('/mock/config/dir/config.json', expect.any(String));
+ expect(process.env.ANTHROPIC_API_KEY).toBe('new-key');
+ });
+ });
+
+ describe('environment specific behavior', () => {
+ it('should prefer env vars in non-CLI environment', () => {
+ process.env.CLI_ENV = 'app';
+ process.env.ANTHROPIC_API_KEY = 'env-key';
+
+ const mockFs = require('fs');
+ mockFs.readFileSync.mockReturnValue(
+ JSON.stringify({
+ ANTHROPIC_API_KEY: 'file-key'
+ })
+ );
+
+ const { getConfigValue, clearConfigCache } = require('../../config');
+ clearConfigCache();
+
+ const value = getConfigValue('ANTHROPIC_API_KEY');
+ expect(value).toBe('env-key');
+ });
+ });
+});
diff --git a/src/shared/config/common.config.ts b/src/shared/config/common-config.ts
similarity index 95%
rename from src/shared/config/common.config.ts
rename to src/shared/config/common-config.ts
index 1f868d6..531bd19 100644
--- a/src/shared/config/common.config.ts
+++ b/src/shared/config/common-config.ts
@@ -2,7 +2,6 @@ import dotenv from 'dotenv';
import { Config } from '.';
-// Load .env file if running locally
if (process.env.NODE_ENV !== 'production') {
dotenv.config();
}
diff --git a/src/shared/config/config.constants.ts b/src/shared/config/constants.ts
similarity index 69%
rename from src/shared/config/config.constants.ts
rename to src/shared/config/constants.ts
index 1313548..d754ffa 100644
--- a/src/shared/config/config.constants.ts
+++ b/src/shared/config/constants.ts
@@ -1,9 +1,7 @@
import * as os from 'os';
import * as path from 'path';
-import { commonConfig } from './common.config';
-
-export const isCliEnvironment = commonConfig.CLI_ENV === 'cli';
+export const isCliEnvironment = process.env.CLI_ENV === 'cli';
export const CONFIG_DIR = isCliEnvironment
? path.join(os.homedir(), '.prompt-library-cli')
diff --git a/src/shared/config/index.ts b/src/shared/config/index.ts
index 516bcd1..65dd5dd 100644
--- a/src/shared/config/index.ts
+++ b/src/shared/config/index.ts
@@ -1,78 +1,86 @@
import * as fs from 'fs';
-import { CommonConfig, commonConfig } from './common.config';
-import { CONFIG_DIR, CONFIG_FILE, isCliEnvironment } from './config.constants';
-import { AppConfig, appConfig } from '../../app/config/app.config';
-import { CliConfig, cliConfig } from '../../cli/cli.config';
+import { CommonConfig, commonConfig } from './common-config';
+import { CONFIG_DIR, CONFIG_FILE, isCliEnvironment } from './constants';
+import { appConfig, AppConfig } from '../../app/config/app-config';
+import { CliConfig, cliConfig } from '../../cli/config/cli-config';
export type Config = CommonConfig & (CliConfig | AppConfig);
let loadedConfig: Config | null = null;
+let lastCliEnv: string | undefined;
+
+export function clearConfigCache(): void {
+ loadedConfig = null;
+ lastCliEnv = undefined;
+}
function loadConfig(): Config {
- const environmentConfig = isCliEnvironment ? cliConfig : appConfig;
- let config: Config = { ...commonConfig, ...environmentConfig };
+ if (process.env.CLI_ENV !== lastCliEnv) {
+ loadedConfig = null;
+ lastCliEnv = process.env.CLI_ENV;
+ }
- if (isCliEnvironment) {
- if (!fs.existsSync(CONFIG_DIR)) {
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
- }
+ if (loadedConfig) {
+ return JSON.parse(JSON.stringify(loadedConfig));
+ }
- if (fs.existsSync(CONFIG_FILE)) {
+ const environmentConfig = isCliEnvironment ? cliConfig : appConfig;
+ let config = {
+ ...commonConfig,
+ ...environmentConfig
+ } as Config;
+
+ if (isCliEnvironment && fs.existsSync(CONFIG_FILE)) {
+ try {
const fileConfig = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
config = { ...config, ...fileConfig };
+ } catch (error) {
+ console.error('Error loading config file:', error);
}
}
+
+ loadedConfig = JSON.parse(JSON.stringify(config));
return config;
}
+export function getConfig(): Readonly {
+ return loadConfig();
+}
+
export function setConfig(key: K, value: Config[K]): void {
- if (!isCliEnvironment) {
+ if (process.env.CLI_ENV !== 'cli') {
throw new Error('setConfig is only available in CLI environment');
}
- if (!loadedConfig) {
- loadedConfig = loadConfig();
+ if (!fs.existsSync(CONFIG_DIR)) {
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
}
- loadedConfig[key] = value;
+ const config = loadConfig();
+ const updatedConfig = { ...config, [key]: value };
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(updatedConfig, null, 2));
- // Update process.env to reflect the change
- if (typeof value === 'string') {
- process.env[key.toString()] = value;
- }
-
- // Write to file
- fs.writeFileSync(CONFIG_FILE, JSON.stringify(loadedConfig, null, 2));
-
- // Clear the cache by reassigning loadedConfig to null
- loadedConfig = null as unknown as Config;
-}
+ loadedConfig = updatedConfig;
-export function getConfig(): Readonly {
- if (loadedConfig === null) {
- loadedConfig = loadConfig();
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
+ process.env[key] = String(value);
}
- return loadedConfig;
}
export function getConfigValue(key: K): Config[K] {
- if (loadedConfig === null) {
- loadedConfig = loadConfig();
- }
+ const config = loadConfig();
- if (isCliEnvironment) {
- return loadedConfig[key];
- } else {
- const envValue = process.env[key.toString()];
- return (envValue !== undefined ? envValue : loadedConfig[key]) as Config[K];
- }
-}
-
-type ConfigValue = Config[keyof Config];
+ if (!isCliEnvironment && process.env[key] !== undefined) {
+ const envValue = process.env[key];
+ const currentValue = config[key];
-export const config: Readonly = new Proxy({} as Config, {
- get(_, prop: string): ConfigValue {
- return getConfigValue(prop as keyof Config);
+ if (typeof currentValue === 'number') {
+ return Number(envValue) as Config[K];
+ } else if (typeof currentValue === 'boolean') {
+ return (envValue?.toLowerCase() === 'true') as unknown as Config[K];
+ }
+ return envValue as Config[K];
}
-});
+ return config[key];
+}
diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts
index bdee51e..58ed1bd 100644
--- a/src/shared/types/index.ts
+++ b/src/shared/types/index.ts
@@ -6,15 +6,6 @@ export interface EnvVar {
prompt_id?: number;
}
-export interface Prompt {
- id: string;
- title: string;
- primary_category: string;
- description?: string;
- tags?: string | string[];
- variables: Variable[];
-}
-
export interface CategoryItem {
id: string;
title: string;
@@ -37,12 +28,13 @@ export type ApiResult = {
error?: string;
};
-export interface Metadata {
+export interface PromptMetadata {
+ id?: string;
title: string;
primary_category: string;
subcategories: string[];
directory: string;
- tags: string[];
+ tags: string | string[];
one_line_description: string;
description: string;
variables: Variable[];
diff --git a/src/shared/utils/__tests__/anthropic-client.test.ts b/src/shared/utils/__tests__/anthropic-client.test.ts
new file mode 100644
index 0000000..dc506f1
--- /dev/null
+++ b/src/shared/utils/__tests__/anthropic-client.test.ts
@@ -0,0 +1,175 @@
+import { Anthropic } from '@anthropic-ai/sdk';
+import { Message, MessageStreamEvent } from '@anthropic-ai/sdk/resources/messages.js';
+
+import { commonConfig } from '../../config/common-config';
+import { sendAnthropicRequestClassic, sendAnthropicRequestStream, validateAnthropicApiKey } from '../anthropic-client';
+
+jest.mock('@anthropic-ai/sdk');
+jest.mock('../../../cli/utils/errors');
+jest.mock('../../config/common-config');
+
+describe('AnthropicClientUtils', () => {
+ const testMessages = [
+ { role: 'user', content: 'Hello' },
+ { role: 'assistant', content: 'Hi there' }
+ ];
+ const mockResponse = {
+ id: 'msg_123',
+ type: 'message',
+ role: 'assistant',
+ content: [{ type: 'text', text: 'Test response' }],
+ model: 'claude-3',
+ stop_reason: null,
+ stop_sequence: null,
+ usage: { input_tokens: 10, output_tokens: 20 }
+ } as Message;
+ beforeEach(() => {
+ jest.clearAllMocks();
+ process.env.ANTHROPIC_API_KEY = 'test-key';
+ });
+
+ afterEach(() => {
+ delete process.env.ANTHROPIC_API_KEY;
+ });
+
+ describe('sendAnthropicRequestClassic', () => {
+ it('should successfully send a classic request', async () => {
+ const mockCreate = jest.fn().mockResolvedValue(mockResponse);
+ const mockMessagesAPI = {
+ create: mockCreate,
+ stream: jest.fn()
+ } as unknown as typeof Anthropic.prototype.messages;
+ (Anthropic as jest.MockedClass).prototype.messages = mockMessagesAPI;
+
+ const result = await sendAnthropicRequestClassic(testMessages);
+ expect(result).toBe(mockResponse);
+ expect(mockCreate).toHaveBeenCalledWith({
+ model: commonConfig.ANTHROPIC_MODEL,
+ max_tokens: commonConfig.ANTHROPIC_MAX_TOKENS,
+ messages: testMessages.map((msg) => ({
+ role: msg.role === 'user' ? 'user' : 'assistant',
+ content: msg.content
+ }))
+ });
+ });
+
+ it('should handle API errors properly', async () => {
+ const mockError = new Error('API Error');
+ const mockCreate = jest.fn().mockRejectedValue(mockError);
+ (Anthropic as jest.MockedClass).prototype.messages = {
+ create: mockCreate,
+ stream: jest.fn(),
+ _client: {} as any
+ } as any;
+
+ await expect(sendAnthropicRequestClassic(testMessages)).rejects.toThrow(mockError);
+ });
+ });
+
+ describe('sendAnthropicRequestStream', () => {
+ it('should successfully stream responses', async () => {
+ const mockStreamEvents: MessageStreamEvent[] = [
+ {
+ type: 'message_start',
+ message: {
+ id: 'msg_123',
+ type: 'message',
+ role: 'assistant',
+ content: [],
+ model: 'claude-3',
+ stop_reason: null,
+ stop_sequence: null,
+ usage: { input_tokens: 0, output_tokens: 0 }
+ }
+ },
+ { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } },
+ { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'Test' } },
+ { type: 'content_block_stop', index: 0 },
+ {
+ type: 'message_delta',
+ delta: {
+ stop_reason: 'end_turn',
+ stop_sequence: null
+ },
+ usage: { output_tokens: 1 }
+ },
+ { type: 'message_stop' }
+ ];
+ const mockStream = {
+ [Symbol.asyncIterator]: async function* (): AsyncIterableIterator {
+ for (const event of mockStreamEvents) {
+ yield event;
+ }
+ }
+ };
+ const mockStreamFn = jest.fn().mockReturnValue(mockStream);
+ const mockMessagesAPI = {
+ stream: mockStreamFn,
+ create: jest.fn()
+ } as unknown as typeof Anthropic.prototype.messages;
+ (Anthropic as jest.MockedClass).prototype.messages = mockMessagesAPI;
+
+ const events: MessageStreamEvent[] = [];
+
+ for await (const event of sendAnthropicRequestStream(testMessages)) {
+ events.push(event);
+ }
+
+ expect(events).toEqual(mockStreamEvents);
+ expect(mockStreamFn).toHaveBeenCalledWith({
+ model: commonConfig.ANTHROPIC_MODEL,
+ max_tokens: commonConfig.ANTHROPIC_MAX_TOKENS,
+ messages: testMessages.map((msg) => ({
+ role: msg.role === 'user' ? 'user' : 'assistant',
+ content: msg.content
+ }))
+ });
+ });
+
+ it('should handle streaming errors properly', async () => {
+ const mockError = new Error('Stream Error');
+ const mockStreamFn = jest.fn().mockImplementation(() => {
+ throw mockError;
+ });
+ (Anthropic as jest.MockedClass).prototype.messages = {
+ stream: mockStreamFn,
+ create: jest.fn(),
+ _client: {} as any
+ } as any;
+
+ const generator = sendAnthropicRequestStream(testMessages);
+ await expect(generator.next()).rejects.toThrow(mockError);
+ });
+ });
+
+ describe('validateAnthropicApiKey', () => {
+ it('should return true for valid API key', async () => {
+ const mockCreate = jest.fn().mockResolvedValue(mockResponse);
+ (Anthropic as jest.MockedClass).prototype.messages = {
+ create: mockCreate,
+ stream: jest.fn(),
+ _client: {} as any
+ } as any;
+
+ const result = await validateAnthropicApiKey();
+ expect(result).toBe(true);
+ expect(mockCreate).toHaveBeenCalledWith({
+ model: commonConfig.ANTHROPIC_MODEL,
+ max_tokens: commonConfig.ANTHROPIC_MAX_TOKENS,
+ messages: [{ role: 'user', content: 'Test request' }]
+ });
+ });
+
+ it('should return false for invalid API key', async () => {
+ const mockCreate = jest.fn().mockRejectedValue(new Error('Invalid API key'));
+ (Anthropic as jest.MockedClass).prototype.messages = {
+ create: mockCreate,
+ stream: jest.fn(),
+ _client: {} as any
+ } as any;
+
+ const result = await validateAnthropicApiKey();
+ expect(result).toBe(false);
+ });
+ });
+});
diff --git a/src/shared/utils/__tests__/file-system.test.ts b/src/shared/utils/__tests__/file-system.test.ts
new file mode 100644
index 0000000..8fa1b4a
--- /dev/null
+++ b/src/shared/utils/__tests__/file-system.test.ts
@@ -0,0 +1,224 @@
+import { Dirent, Stats } from 'fs';
+import * as fs from 'fs/promises';
+
+import { jest } from '@jest/globals';
+
+import { handleError } from '../../../cli/utils/errors';
+import {
+ readFileContent,
+ writeFileContent,
+ readDirectory,
+ createDirectory,
+ renameFile,
+ copyFile,
+ removeDirectory,
+ fileExists,
+ isDirectory,
+ isFile
+} from '../file-system';
+
+jest.mock('fs/promises');
+const mockFs = jest.mocked(fs);
+jest.mock('../../../cli/utils/errors');
+const mockHandleError = jest.mocked(handleError);
+describe('FileSystemUtils', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('readFileContent', () => {
+ it('should read file content successfully', async () => {
+ const testContent = 'test content';
+ mockFs.readFile.mockResolvedValue(testContent);
+
+ const result = await readFileContent('test.txt');
+ expect(result).toBe(testContent);
+ expect(mockFs.readFile).toHaveBeenCalledWith('test.txt', 'utf-8');
+ });
+
+ it('should handle errors when reading file', async () => {
+ const error = new Error('Read error');
+ mockFs.readFile.mockRejectedValue(error);
+
+ await expect(readFileContent('test.txt')).rejects.toThrow(error);
+ expect(mockHandleError).toHaveBeenCalledWith(error, 'reading file test.txt');
+ });
+ });
+
+ describe('writeFileContent', () => {
+ it('should write file content successfully', async () => {
+ const testContent = 'test content';
+ mockFs.writeFile.mockResolvedValue();
+
+ await writeFileContent('test.txt', testContent);
+
+ expect(mockFs.writeFile).toHaveBeenCalledWith('test.txt', testContent, 'utf-8');
+ });
+
+ it('should handle errors when writing file', async () => {
+ const error = new Error('Write error');
+ mockFs.writeFile.mockRejectedValue(error);
+
+ await expect(writeFileContent('test.txt', 'content')).rejects.toThrow(error);
+ expect(mockHandleError).toHaveBeenCalledWith(error, 'writing file test.txt');
+ });
+ });
+
+ describe('readDirectory', () => {
+ it('should read directory contents successfully', async () => {
+ const testFiles = ['file1.txt', 'file2.txt'];
+ (mockFs.readdir as jest.MockedFunction).mockResolvedValue(
+ testFiles as unknown as Dirent[]
+ );
+
+ const result = await readDirectory('testDir');
+ expect(result).toEqual(testFiles);
+ expect(mockFs.readdir).toHaveBeenCalledWith('testDir', { withFileTypes: false });
+ });
+
+ it('should handle errors when reading directory', async () => {
+ const error = new Error('Read dir error');
+ mockFs.readdir.mockRejectedValue(error);
+
+ await expect(readDirectory('testDir')).rejects.toThrow(error);
+ expect(mockHandleError).toHaveBeenCalledWith(error, 'reading directory testDir');
+ });
+ });
+
+ describe('createDirectory', () => {
+ it('should create directory successfully', async () => {
+ mockFs.mkdir.mockResolvedValue(undefined);
+
+ await createDirectory('testDir');
+
+ expect(mockFs.mkdir).toHaveBeenCalledWith('testDir', { recursive: true });
+ });
+
+ it('should handle errors when creating directory', async () => {
+ const error = new Error('Create dir error');
+ mockFs.mkdir.mockRejectedValue(error);
+
+ await expect(createDirectory('testDir')).rejects.toThrow(error);
+ expect(mockHandleError).toHaveBeenCalledWith(error, 'creating directory testDir');
+ });
+ });
+
+ describe('renameFile', () => {
+ it('should rename file successfully', async () => {
+ mockFs.rename.mockResolvedValue();
+
+ await renameFile('old.txt', 'new.txt');
+
+ expect(mockFs.rename).toHaveBeenCalledWith('old.txt', 'new.txt');
+ });
+
+ it('should handle errors when renaming file', async () => {
+ const error = new Error('Rename error');
+ mockFs.rename.mockRejectedValue(error);
+
+ await expect(renameFile('old.txt', 'new.txt')).rejects.toThrow(error);
+ expect(mockHandleError).toHaveBeenCalledWith(error, 'renaming file from old.txt to new.txt');
+ });
+ });
+
+ describe('copyFile', () => {
+ it('should copy file successfully', async () => {
+ mockFs.copyFile.mockResolvedValue();
+
+ await copyFile('src.txt', 'dst.txt');
+
+ expect(mockFs.copyFile).toHaveBeenCalledWith('src.txt', 'dst.txt');
+ });
+
+ it('should handle errors when copying file', async () => {
+ const error = new Error('Copy error');
+ mockFs.copyFile.mockRejectedValue(error);
+
+ await expect(copyFile('src.txt', 'dst.txt')).rejects.toThrow(error);
+ expect(mockHandleError).toHaveBeenCalledWith(error, 'copying file from src.txt to dst.txt');
+ });
+ });
+
+ describe('removeDirectory', () => {
+ it('should remove directory successfully', async () => {
+ mockFs.rm.mockResolvedValue();
+
+ await removeDirectory('testDir');
+
+ expect(mockFs.rm).toHaveBeenCalledWith('testDir', { recursive: true, force: true });
+ });
+
+ it('should handle errors when removing directory', async () => {
+ const error = new Error('Remove dir error');
+ mockFs.rm.mockRejectedValue(error);
+
+ await expect(removeDirectory('testDir')).rejects.toThrow(error);
+ expect(mockHandleError).toHaveBeenCalledWith(error, 'removing directory testDir');
+ });
+ });
+
+ describe('fileExists', () => {
+ it('should return true when file exists', async () => {
+ mockFs.access.mockResolvedValue();
+
+ const result = await fileExists('test.txt');
+ expect(result).toBe(true);
+ expect(mockFs.access).toHaveBeenCalledWith('test.txt');
+ });
+
+ it('should return false when file does not exist', async () => {
+ mockFs.access.mockRejectedValue(new Error('File not found'));
+
+ const result = await fileExists('test.txt');
+ expect(result).toBe(false);
+ });
+ });
+
+ describe('isDirectory', () => {
+ it('should return true for directories', async () => {
+ mockFs.stat.mockResolvedValue({ isDirectory: () => true } as Stats);
+
+ const result = await isDirectory('testDir');
+ expect(result).toBe(true);
+ expect(mockFs.stat).toHaveBeenCalledWith('testDir');
+ });
+
+ it('should return false for non-directories', async () => {
+ mockFs.stat.mockResolvedValue({ isDirectory: () => false } as Stats);
+
+ const result = await isDirectory('test.txt');
+ expect(result).toBe(false);
+ });
+
+ it('should return false when path does not exist', async () => {
+ mockFs.stat.mockRejectedValue(new Error('Path not found'));
+
+ const result = await isDirectory('nonexistent');
+ expect(result).toBe(false);
+ });
+ });
+
+ describe('isFile', () => {
+ it('should return true for files', async () => {
+ mockFs.stat.mockResolvedValue({ isFile: () => true } as Stats);
+
+ const result = await isFile('test.txt');
+ expect(result).toBe(true);
+ expect(mockFs.stat).toHaveBeenCalledWith('test.txt');
+ });
+
+ it('should return false for non-files', async () => {
+ mockFs.stat.mockResolvedValue({ isFile: () => false } as Stats);
+
+ const result = await isFile('testDir');
+ expect(result).toBe(false);
+ });
+
+ it('should return false when path does not exist', async () => {
+ mockFs.stat.mockRejectedValue(new Error('Path not found'));
+
+ const result = await isFile('nonexistent');
+ expect(result).toBe(false);
+ });
+ });
+});
diff --git a/src/shared/utils/__tests__/logger.test.ts b/src/shared/utils/__tests__/logger.test.ts
new file mode 100644
index 0000000..0dec515
--- /dev/null
+++ b/src/shared/utils/__tests__/logger.test.ts
@@ -0,0 +1,93 @@
+import logger from '../logger';
+
+jest.mock('../../config/common-config', () => ({
+ commonConfig: {
+ LOG_LEVEL: 'debug'
+ }
+}));
+
+describe('LoggerUtils', () => {
+ let consoleSpy: jest.SpyInstance;
+ const originalEnv = process.env;
+ beforeEach(() => {
+ process.env = { ...originalEnv };
+ delete process.env.LOG_LEVEL;
+
+ logger.setLogLevel('debug');
+
+ consoleSpy = jest.spyOn(console, 'log').mockImplementation();
+ });
+
+ afterEach(() => {
+ consoleSpy.mockRestore();
+ process.env = originalEnv;
+ });
+
+ describe('log levels', () => {
+ it('should log at info level', () => {
+ logger.setLogLevel('info');
+ logger.info('test message');
+ expect(consoleSpy).toHaveBeenCalled();
+ const logMessage = consoleSpy.mock.calls[0][0];
+ expect(logMessage).toContain('[INFO]');
+ });
+
+ it('should log at error level', () => {
+ logger.setLogLevel('error');
+ logger.error('test error');
+ expect(consoleSpy).toHaveBeenCalled();
+ const logMessage = consoleSpy.mock.calls[0][0];
+ expect(logMessage).toContain('[ERROR]');
+ });
+
+ it('should log at warn level', () => {
+ logger.setLogLevel('warn');
+ logger.warn('test warning');
+ expect(consoleSpy).toHaveBeenCalled();
+ const logMessage = consoleSpy.mock.calls[0][0];
+ expect(logMessage).toContain('[WARN]');
+ });
+
+ it('should log at debug level', () => {
+ logger.setLogLevel('debug');
+ logger.debug('test debug');
+ expect(consoleSpy).toHaveBeenCalled();
+ const logMessage = consoleSpy.mock.calls[0][0];
+ expect(logMessage).toContain('[DEBUG]');
+ });
+ });
+
+ describe('setLogLevel', () => {
+ it('should respect log level settings', () => {
+ logger.setLogLevel('error');
+
+ logger.debug('test debug');
+ expect(consoleSpy).not.toHaveBeenCalled();
+
+ logger.info('test info');
+ expect(consoleSpy).not.toHaveBeenCalled();
+
+ logger.warn('test warn');
+ expect(consoleSpy).not.toHaveBeenCalled();
+
+ logger.error('test error');
+ expect(consoleSpy).toHaveBeenCalledTimes(1);
+ expect(consoleSpy.mock.calls[0][0]).toContain('[ERROR]');
+ });
+
+ it('should allow all logs at debug level', () => {
+ logger.setLogLevel('debug');
+
+ logger.debug('test debug');
+ logger.info('test info');
+ logger.warn('test warn');
+ logger.error('test error');
+
+ expect(consoleSpy).toHaveBeenCalledTimes(4);
+ expect(consoleSpy.mock.calls[0][0]).toContain('[DEBUG]');
+ expect(consoleSpy.mock.calls[1][0]).toContain('[INFO]');
+ expect(consoleSpy.mock.calls[2][0]).toContain('[WARN]');
+ expect(consoleSpy.mock.calls[3][0]).toContain('[ERROR]');
+ });
+ });
+});
diff --git a/src/shared/utils/__tests__/prompt-processing.test.ts b/src/shared/utils/__tests__/prompt-processing.test.ts
new file mode 100644
index 0000000..c823dc5
--- /dev/null
+++ b/src/shared/utils/__tests__/prompt-processing.test.ts
@@ -0,0 +1,253 @@
+import { Message, RawMessageStreamEvent } from '@anthropic-ai/sdk/resources/messages.js';
+import { jest } from '@jest/globals';
+
+import { sendAnthropicRequestClassic, sendAnthropicRequestStream } from '../anthropic-client';
+import { updatePromptWithVariables, processPromptContent } from '../prompt-processing';
+
+jest.mock('../anthropic-client');
+const mockSendAnthropicRequestClassic = sendAnthropicRequestClassic as jest.MockedFunction<
+ typeof sendAnthropicRequestClassic
+>;
+const mockSendAnthropicRequestStream = sendAnthropicRequestStream as jest.MockedFunction<
+ typeof sendAnthropicRequestStream
+>;
+describe('PromptProcessingUtils', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ jest.spyOn(console, 'log').mockImplementation(() => {});
+ jest.spyOn(console, 'error').mockImplementation(() => {});
+ jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
+ });
+
+ describe('updatePromptWithVariables', () => {
+ it('should replace variables in content with provided values', () => {
+ const content = 'Hello {{name}}, welcome to {{place}}!';
+ const variables = {
+ name: 'John',
+ place: 'Paris'
+ };
+ const result = updatePromptWithVariables(content, variables);
+ expect(result).toBe('Hello John, welcome to Paris!');
+ });
+
+ it('should handle multiple occurrences of the same variable', () => {
+ const content = '{{name}} is {{name}}';
+ const variables = { name: 'John' };
+ const result = updatePromptWithVariables(content, variables);
+ expect(result).toBe('John is John');
+ });
+
+ it('should handle empty variables object', () => {
+ const content = 'Hello {{name}}!';
+ const variables = {};
+ const result = updatePromptWithVariables(content, variables);
+ expect(result).toBe('Hello {{name}}!');
+ });
+
+ it('should throw error for null or undefined content', () => {
+ expect(() => {
+ updatePromptWithVariables(null as unknown as string, {});
+ }).toThrow('Content cannot be null or undefined');
+ });
+
+ it('should throw error if variable value is not a string', () => {
+ const content = 'Hello {{name}}!';
+ const variables = { name: 123 as any };
+ expect(() => {
+ updatePromptWithVariables(content, variables);
+ }).toThrow('Variable value for key "name" must be a string');
+ });
+ });
+
+ describe('processPromptContent', () => {
+ const mockMessages = [
+ { role: 'user', content: 'Hello' },
+ { role: 'assistant', content: 'Hi there!' }
+ ];
+ it('should process classic response correctly', async () => {
+ const mockMessage: Message = {
+ id: 'msg_123',
+ type: 'message',
+ role: 'assistant',
+ content: [{ type: 'text', text: 'Test response' }],
+ model: 'claude-2',
+ stop_reason: null,
+ stop_sequence: null,
+ usage: { input_tokens: 10, output_tokens: 20 }
+ };
+ mockSendAnthropicRequestClassic.mockResolvedValue(mockMessage);
+
+ const result = await processPromptContent(mockMessages, false, false);
+ expect(result).toBe('Test response');
+ expect(mockSendAnthropicRequestClassic).toHaveBeenCalledWith(mockMessages);
+ });
+
+ it('should process streaming response correctly', async () => {
+ const mockStream = [
+ { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'Hello' } as const },
+ { type: 'content_block_delta', index: 1, delta: { type: 'text_delta', text: ' world' } as const }
+ ];
+ mockSendAnthropicRequestStream.mockImplementation(async function* () {
+ for (const event of mockStream) {
+ yield event as RawMessageStreamEvent;
+ }
+ });
+
+ const result = await processPromptContent(mockMessages, true, false);
+ expect(result).toBe('Hello world');
+ expect(mockSendAnthropicRequestStream).toHaveBeenCalledWith(mockMessages);
+ });
+
+ it('should handle complex message content', async () => {
+ const mockMessage: Message = {
+ id: 'msg_123',
+ type: 'message',
+ role: 'assistant',
+ content: [
+ { type: 'text', text: 'Text content' },
+ {
+ type: 'tool_use',
+ id: 'tool_123',
+ name: 'calculator',
+ input: { expression: '2+2' }
+ }
+ ],
+ model: 'claude-2',
+ stop_reason: null,
+ stop_sequence: null,
+ usage: { input_tokens: 10, output_tokens: 20 }
+ };
+ mockSendAnthropicRequestClassic.mockResolvedValue(mockMessage);
+
+ const result = await processPromptContent(mockMessages, false, false);
+ expect(result).toContain('Text content');
+ expect(result).toContain('[Tool Use: calculator]');
+ expect(result).toContain('Input: {"expression":"2+2"}');
+ });
+
+ it('should handle unknown block types in message content', async () => {
+ const mockMessage: Message = {
+ id: 'msg_456',
+ type: 'message',
+ role: 'assistant',
+ content: [{ type: 'text', text: 'Known text' }, { type: 'unknown_type', data: 'Some data' } as any],
+ model: 'claude-2',
+ stop_reason: null,
+ stop_sequence: null,
+ usage: { input_tokens: 15, output_tokens: 25 }
+ };
+ mockSendAnthropicRequestClassic.mockResolvedValue(mockMessage);
+
+ const result = await processPromptContent(mockMessages, false, false);
+ expect(result).toContain('Known text');
+ expect(result).toContain(JSON.stringify({ type: 'unknown_type', data: 'Some data' }));
+ });
+
+ it('should handle invalid blocks in message content', async () => {
+ const mockMessage: Message = {
+ id: 'msg_789',
+ type: 'message',
+ role: 'assistant',
+ content: [{ type: 'text', text: 'Valid text' }, null as any, undefined as any],
+ model: 'claude-2',
+ stop_reason: null,
+ stop_sequence: null,
+ usage: { input_tokens: 20, output_tokens: 30 }
+ };
+ mockSendAnthropicRequestClassic.mockResolvedValue(mockMessage);
+
+ const result = await processPromptContent(mockMessages, false, false);
+ expect(result).toBe('Valid text');
+ });
+
+ it('should handle errors in classic mode', async () => {
+ mockSendAnthropicRequestClassic.mockRejectedValue(new Error('API Error'));
+
+ await expect(processPromptContent(mockMessages, false, false)).rejects.toThrow('API Error');
+ });
+
+ it('should handle errors in streaming mode', async () => {
+ mockSendAnthropicRequestStream.mockImplementation(async function* () {
+ yield { type: 'placeholder' } as unknown as RawMessageStreamEvent;
+ throw new Error('Stream Error');
+ });
+
+ await expect(processPromptContent(mockMessages, true, false)).rejects.toThrow('Stream Error');
+ });
+
+ it('should throw error if messages array is empty', async () => {
+ await expect(processPromptContent([], false, false)).rejects.toThrow('Messages must be a non-empty array');
+ });
+
+ it('should log messages when logging is true', async () => {
+ const mockMessage: Message = {
+ id: 'msg_101',
+ type: 'message',
+ role: 'assistant',
+ content: [{ type: 'text', text: 'Logged response' }],
+ model: 'claude-2',
+ stop_reason: null,
+ stop_sequence: null,
+ usage: { input_tokens: 5, output_tokens: 15 }
+ };
+ mockSendAnthropicRequestClassic.mockResolvedValue(mockMessage);
+
+ const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
+ await processPromptContent(mockMessages, false, true);
+
+ expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('You:'));
+ expect(consoleLogSpy).toHaveBeenCalledWith('Hi there!');
+ expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('AI:'));
+
+ consoleLogSpy.mockRestore();
+ });
+
+ it('should return empty string if message content is empty', async () => {
+ const mockMessage: Message = {
+ id: 'msg_202',
+ type: 'message',
+ role: 'assistant',
+ content: [],
+ model: 'claude-2',
+ stop_reason: null,
+ stop_sequence: null,
+ usage: { input_tokens: 0, output_tokens: 0 }
+ };
+ mockSendAnthropicRequestClassic.mockResolvedValue(mockMessage);
+
+ const result = await processPromptContent(mockMessages, false, false);
+ expect(result).toBe('');
+ });
+
+ it('should process streaming response with partial_json correctly', async () => {
+ const mockStream = [
+ {
+ type: 'content_block_delta',
+ index: 0,
+ delta: { type: 'partial_json', partial_json: '{"key":' } as const
+ },
+ {
+ type: 'content_block_delta',
+ index: 1,
+ delta: { type: 'partial_json', partial_json: '"value"}' } as const
+ }
+ ];
+ mockSendAnthropicRequestStream.mockImplementation(async function* () {
+ for (const event of mockStream) {
+ yield event as RawMessageStreamEvent;
+ }
+ });
+
+ const processStdoutWriteSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
+ const result = await processPromptContent(mockMessages, true, false);
+ expect(result).toBe('{"key":"value"}');
+ expect(mockSendAnthropicRequestStream).toHaveBeenCalledWith(mockMessages);
+
+ expect(processStdoutWriteSpy).toHaveBeenCalledWith('{"key":');
+ expect(processStdoutWriteSpy).toHaveBeenCalledWith('"value"}');
+
+ processStdoutWriteSpy.mockRestore();
+ });
+ });
+});
diff --git a/src/shared/utils/__tests__/string-formatter.test.ts b/src/shared/utils/__tests__/string-formatter.test.ts
new file mode 100644
index 0000000..e530ecd
--- /dev/null
+++ b/src/shared/utils/__tests__/string-formatter.test.ts
@@ -0,0 +1,23 @@
+import { formatTitleCase, formatSnakeCase } from '../string-formatter';
+
+describe('StringFormatterUtils', () => {
+ describe('formatTitleCase', () => {
+ it('should format string to title case', () => {
+ expect(formatTitleCase('hello_world')).toBe('Hello World');
+ expect(formatTitleCase('test-case')).toBe('Test Case');
+ });
+ });
+
+ describe('formatSnakeCase', () => {
+ it('should format string to snake case', () => {
+ expect(formatSnakeCase('Hello World')).toBe('hello_world');
+ expect(formatSnakeCase('TestCase')).toBe('test_case');
+ expect(formatSnakeCase('{TEST_VAR}')).toBe('test_var');
+ });
+
+ it('should handle special characters', () => {
+ expect(formatSnakeCase('Hello {World}')).toBe('hello_world');
+ expect(formatSnakeCase('TEST_VAR')).toBe('test_var');
+ });
+ });
+});
diff --git a/src/shared/utils/anthropic_client.util.ts b/src/shared/utils/anthropic-client.ts
similarity index 94%
rename from src/shared/utils/anthropic_client.util.ts
rename to src/shared/utils/anthropic-client.ts
index 5bfab25..bdcdf36 100644
--- a/src/shared/utils/anthropic_client.util.ts
+++ b/src/shared/utils/anthropic-client.ts
@@ -1,9 +1,9 @@
import { Anthropic } from '@anthropic-ai/sdk';
import { Message, MessageStreamEvent } from '@anthropic-ai/sdk/resources';
-import { AppError, handleError } from '../../cli/utils/error.util';
+import { AppError, handleError } from '../../cli/utils/errors';
import { getConfigValue } from '../config';
-import { commonConfig } from '../config/common.config';
+import { commonConfig } from '../config/common-config';
let anthropicClient: Anthropic | null = null;
diff --git a/src/shared/utils/file_system.util.ts b/src/shared/utils/file-system.ts
similarity index 93%
rename from src/shared/utils/file_system.util.ts
rename to src/shared/utils/file-system.ts
index ba6ea09..755aeac 100644
--- a/src/shared/utils/file_system.util.ts
+++ b/src/shared/utils/file-system.ts
@@ -1,6 +1,6 @@
import * as fs from 'fs/promises';
-import { handleError } from '../../cli/utils/error.util';
+import { handleError } from '../../cli/utils/errors';
export async function readFileContent(filePath: string): Promise {
try {
@@ -22,7 +22,8 @@ export async function writeFileContent(filePath: string, content: string): Promi
export async function readDirectory(dirPath: string): Promise {
try {
- return await fs.readdir(dirPath);
+ const entries = await fs.readdir(dirPath, { withFileTypes: false });
+ return entries;
} catch (error) {
handleError(error, `reading directory ${dirPath}`);
throw error;
diff --git a/src/shared/utils/logger.util.ts b/src/shared/utils/logger.ts
similarity index 86%
rename from src/shared/utils/logger.util.ts
rename to src/shared/utils/logger.ts
index 85e2a90..5a1885b 100644
--- a/src/shared/utils/logger.util.ts
+++ b/src/shared/utils/logger.ts
@@ -1,6 +1,6 @@
import chalk from 'chalk';
-import { commonConfig } from '../config/common.config';
+import { commonConfig } from '../config/common-config';
enum LogLevel {
DEBUG,
@@ -33,8 +33,9 @@ function normalizeLogLevel(level: LogLevelKey): keyof typeof LogLevel {
class ConfigurableLogger implements Logger {
private currentLogLevel: LogLevel;
- constructor(initialLogLevel: LogLevelKey = commonConfig.LOG_LEVEL) {
- this.currentLogLevel = LogLevel[normalizeLogLevel(initialLogLevel)];
+ constructor(initialLogLevel?: LogLevelKey) {
+ const logLevel = initialLogLevel || (process.env.LOG_LEVEL as LogLevelKey) || commonConfig.LOG_LEVEL;
+ this.currentLogLevel = LogLevel[normalizeLogLevel(logLevel)];
}
setLogLevel(level: LogLevelKey): void {
diff --git a/src/shared/utils/prompt_processing.util.ts b/src/shared/utils/prompt-processing.ts
similarity index 71%
rename from src/shared/utils/prompt_processing.util.ts
rename to src/shared/utils/prompt-processing.ts
index 227b360..b93ab2d 100644
--- a/src/shared/utils/prompt_processing.util.ts
+++ b/src/shared/utils/prompt-processing.ts
@@ -1,13 +1,21 @@
import { Message } from '@anthropic-ai/sdk/resources';
import chalk from 'chalk';
-import { sendAnthropicRequestClassic, sendAnthropicRequestStream } from './anthropic_client.util';
-import { handleError } from '../../cli/utils/error.util';
+import { sendAnthropicRequestClassic, sendAnthropicRequestStream } from './anthropic-client';
+import { handleError } from '../../cli/utils/errors';
export function updatePromptWithVariables(content: string, variables: Record): string {
+ if (content === null || content === undefined) {
+ throw new Error('Content cannot be null or undefined');
+ }
+
try {
return Object.entries(variables).reduce((updatedContent, [key, value]) => {
- const regex = new RegExp(`{{${key.replace(/{{|}}/g, '')}}}`, 'g');
+ if (typeof value !== 'string') {
+ throw new Error(`Variable value for key "${key}" must be a string`);
+ }
+
+ const regex = new RegExp(`{{${key.replace(/[{}]/g, '')}}}`, 'g');
return updatedContent.replace(regex, value);
}, content);
} catch (error) {
@@ -17,18 +25,23 @@ export function updatePromptWithVariables(content: string, variables: Record {
+ if (!block || typeof block !== 'object') {
+ return '';
+ }
+
if (block.type === 'text') {
- return block.text;
+ return block.text || '';
} else if (block.type === 'tool_use') {
return `[Tool Use: ${block.name}]\nInput: ${JSON.stringify(block.input)}`;
}
return JSON.stringify(block);
})
+ .filter(Boolean)
.join('\n');
}
@@ -37,6 +50,10 @@ export async function processPromptContent(
useStreaming: boolean = false,
logging: boolean = true
): Promise {
+ if (!Array.isArray(messages) || messages.length === 0) {
+ throw new Error('Messages must be a non-empty array');
+ }
+
try {
if (logging) {
console.log(chalk.blue(chalk.bold('\nYou:')));
@@ -57,11 +74,15 @@ export async function processPromptContent(
}
async function processStreamingResponse(messages: { role: string; content: string }[]): Promise {
+ if (!Array.isArray(messages)) {
+ throw new Error('Messages must be an array');
+ }
+
let fullResponse = '';
try {
for await (const event of sendAnthropicRequestStream(messages)) {
- if (event.type === 'content_block_delta' && event.delta) {
+ if (event?.type === 'content_block_delta' && event.delta) {
if ('text' in event.delta) {
fullResponse += event.delta.text;
process.stdout.write(event.delta.text);
diff --git a/src/shared/utils/string_formatter.util.ts b/src/shared/utils/string-formatter.ts
similarity index 62%
rename from src/shared/utils/string_formatter.util.ts
rename to src/shared/utils/string-formatter.ts
index 19bfd42..dc2fded 100644
--- a/src/shared/utils/string_formatter.util.ts
+++ b/src/shared/utils/string-formatter.ts
@@ -1,6 +1,6 @@
export function formatTitleCase(category: string): string {
return category
- .split('_')
+ .split(/[_-]/)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ');
}
@@ -8,9 +8,9 @@ export function formatTitleCase(category: string): string {
export function formatSnakeCase(variableName: string): string {
return variableName
.replace(/[{}]/g, '')
+ .replace(/([a-z])([A-Z])/g, '$1_$2')
.toLowerCase()
- .replace(/_/g, ' ')
- .replace(/\w\S*/g, (word) => word.charAt(0).toUpperCase() + word.substr(1).toLowerCase())
- .replace(/ /g, '_')
- .toLowerCase();
+ .replace(/[-\s_]+/g, '_')
+ .replace(/^_/, '')
+ .replace(/_$/g, '');
}
diff --git a/tests/.gitkeep b/tests/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/tsconfig.json b/tsconfig.json
index 83556d5..e2c7e75 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -15,5 +15,5 @@
"typeRoots": ["src/shared/types", "./node_modules/@types"]
},
"include": ["src/**/*"],
- "exclude": ["node_modules", "dist", "tests"]
+ "exclude": ["node_modules", "dist", "**/__tests__/**"]
}
\ No newline at end of file
diff --git a/tsconfig.test.json b/tsconfig.test.json
index d2f7a15..efe911b 100644
--- a/tsconfig.test.json
+++ b/tsconfig.test.json
@@ -2,7 +2,10 @@
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "commonjs",
- "types": ["jest", "node"]
+ "types": ["jest", "node"],
+ "rootDir": ".",
+ "noEmit": true
},
- "include": ["src/**/*", "tests/**/*"]
+ "include": ["src/**/*", "src/**/__tests__/**/*"],
+ "exclude": ["node_modules", "dist"]
}
\ No newline at end of file