diff --git a/python/prompd/cli.py b/python/prompd/cli.py index 10e9833..a5b52ab 100644 --- a/python/prompd/cli.py +++ b/python/prompd/cli.py @@ -96,6 +96,7 @@ def _run_impl( actual_content = actual_file.read_text(encoding="utf-8") if not actual_content.startswith("---"): import re + stem = actual_file.stem kebab_name = re.sub(r"[^a-z0-9]+", "-", stem.lower()).strip("-") or "prompt" frontmatter = f"---\nname: {kebab_name}\nversion: 1.0.0\n---\n\n" diff --git a/python/prompd/section_override_processor.py b/python/prompd/section_override_processor.py index 8fa3e0b..d483dfe 100644 --- a/python/prompd/section_override_processor.py +++ b/python/prompd/section_override_processor.py @@ -398,8 +398,7 @@ def _load_file_with_encoding(self, file_path: Path) -> str: resolved_path = file_path.resolve(strict=True) except (OSError, ValueError) as exc: raise ValidationError( - "Unable to resolve override file path. " - "Please verify the file exists and is accessible." + "Unable to resolve override file path. " "Please verify the file exists and is accessible." ) from exc # Security Control 2: Verify resolved path is within the allowed base directory diff --git a/python/prompd/security.py b/python/prompd/security.py index 8ba09ad..a1cb5f7 100644 --- a/python/prompd/security.py +++ b/python/prompd/security.py @@ -182,6 +182,7 @@ def validate_version_string(version: str) -> str: # --- Secrets Detection --- + @dataclass class SecretMatch: """Represents a detected secret in content or a file.""" @@ -198,15 +199,11 @@ class SecretMatch: "OpenAI API Key": re.compile(r"sk-[a-zA-Z0-9]{20,}"), "Anthropic API Key": re.compile(r"sk-ant-[a-zA-Z0-9_-]{20,}"), "AWS Access Key": re.compile(r"AKIA[0-9A-Z]{16}"), - "AWS Secret Key": re.compile( - r"(?i)aws[_-]?secret[_-]?access[_-]?key[=:\s]+['\"]?[a-zA-Z0-9/+=]{40}['\"]?" - ), + "AWS Secret Key": re.compile(r"(?i)aws[_-]?secret[_-]?access[_-]?key[=:\s]+['\"]?[a-zA-Z0-9/+=]{40}['\"]?"), "GitHub Token": re.compile(r"gh[ps]_[a-zA-Z0-9]{36}"), "GitHub Fine-Grained": re.compile(r"github_pat_[a-zA-Z0-9_]{22,}"), "Prompd Registry Token": re.compile(r"prompd_[a-zA-Z0-9]{32,}"), - "Private Key": re.compile( - r"-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----" - ), + "Private Key": re.compile(r"-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----"), "Generic API Key": re.compile( r"(?i)(?:api[_-]?key|apikey|api_secret|apisecret)[_-]?[=:]\s*['\"]?([a-zA-Z0-9_\-]{20,})['\"]?" ), @@ -214,12 +211,8 @@ class SecretMatch: r"(?i)(?:secret|password|passwd|token)[_-]?[=:]\s*['\"]?([a-zA-Z0-9_\-!@#$%^&*]{16,})['\"]?" ), "Bearer Token": re.compile(r"[Bb]earer\s+[a-zA-Z0-9_\-.]{32,256}"), - "JWT Token": re.compile( - r"eyJ[a-zA-Z0-9_-]{10,}\.eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}" - ), - "URL-Embedded Credentials": re.compile( - r"https?://[^:\s]+:[^@\s]+@[a-zA-Z0-9.-]+" - ), + "JWT Token": re.compile(r"eyJ[a-zA-Z0-9_-]{10,}\.eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}"), + "URL-Embedded Credentials": re.compile(r"https?://[^:\s]+:[^@\s]+@[a-zA-Z0-9.-]+"), "Slack Token": re.compile(r"xox[bpors]-[a-zA-Z0-9-]{10,}"), "Google API Key": re.compile(r"AIza[0-9A-Za-z_-]{35}"), "Stripe Key": re.compile(r"(?:sk|pk)_(?:test|live)_[a-zA-Z0-9]{20,}"), @@ -227,17 +220,55 @@ class SecretMatch: # File extensions considered as text files for secrets scanning _TEXT_EXTENSIONS = { - ".prmd", ".pdproj", ".yaml", ".yml", ".json", ".md", ".txt", - ".py", ".go", ".js", ".ts", ".tsx", ".jsx", ".sh", ".bat", - ".env", ".html", ".css", ".xml", ".toml", ".ini", ".conf", - ".c", ".cpp", ".h", ".hpp", ".java", ".cs", ".rb", ".php", - ".sql", ".r", ".lua", ".pl", ".swift", ".kt", ".rs", + ".prmd", + ".pdproj", + ".yaml", + ".yml", + ".json", + ".md", + ".txt", + ".py", + ".go", + ".js", + ".ts", + ".tsx", + ".jsx", + ".sh", + ".bat", + ".env", + ".html", + ".css", + ".xml", + ".toml", + ".ini", + ".conf", + ".c", + ".cpp", + ".h", + ".hpp", + ".java", + ".cs", + ".rb", + ".php", + ".sql", + ".r", + ".lua", + ".pl", + ".swift", + ".kt", + ".rs", } # Files that commonly contain secrets and should be excluded from packaging _SECRET_FILE_NAMES = { - ".env", ".env.local", ".env.production", ".env.development", - ".env.test", "credentials.json", "secrets.yaml", "secrets.yml", + ".env", + ".env.local", + ".env.production", + ".env.development", + ".env.test", + "credentials.json", + "secrets.yaml", + "secrets.yml", "private.key", } @@ -261,8 +292,15 @@ def _is_text_file(file_path: str) -> bool: base_name = os.path.basename(file_path).lower() no_ext_patterns = [ - "readme", "license", "makefile", "dockerfile", "vagrantfile", - ".env", ".gitignore", ".dockerignore", ".npmignore", + "readme", + "license", + "makefile", + "dockerfile", + "vagrantfile", + ".env", + ".gitignore", + ".dockerignore", + ".npmignore", ] for pattern in no_ext_patterns: if base_name == pattern or base_name.startswith(pattern + "."): @@ -314,7 +352,7 @@ def scan_file_for_secrets(file_path: str) -> List[SecretMatch]: try: with open(file_path, encoding="utf-8", errors="replace") as f: content = f.read() - except (OSError, IOError): + except OSError: return [] matches = detect_secrets_in_content(content) diff --git a/typescript/package.json b/typescript/package.json index e6d6454..70b4428 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@prompd/cli", - "version": "0.5.0-beta.3", + "version": "0.5.0-beta.10", "description": "Node.js CLI for structured prompt definitions with universal LLM provider support. Also usable as a library in TypeScript/React apps.", "main": "dist/lib/index.js", "types": "dist/lib/index.d.ts", @@ -68,7 +68,6 @@ "adm-zip": "^0.5.16", "archiver": "^6.0.1", "axios": "^1.6.2", - "bcrypt": "^6.0.0", "chalk": "^4.1.2", "commander": "^11.1.0", "compression": "^1.7.4", @@ -93,7 +92,6 @@ "devDependencies": { "@types/adm-zip": "^0.5.7", "@types/archiver": "^6.0.2", - "@types/bcrypt": "^5.0.2", "@types/compression": "^1.7.5", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", diff --git a/typescript/prompd.json b/typescript/prompd.json new file mode 100644 index 0000000..c0c3bc9 --- /dev/null +++ b/typescript/prompd.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "@prompd/core": "0.0.2" + } +} diff --git a/typescript/src/commands/package.ts b/typescript/src/commands/package.ts index de31552..34a2767 100644 --- a/typescript/src/commands/package.ts +++ b/typescript/src/commands/package.ts @@ -5,8 +5,11 @@ import * as path from 'path'; import * as yaml from 'js-yaml'; import archiver from 'archiver'; import { createHash } from 'crypto'; +import * as xlsx from 'xlsx'; +import mammoth from 'mammoth'; import { SecurityManager } from '../lib/security'; import { PrompdCompiler, NodeFileSystem } from '../lib/compiler'; +import { PrompdParser } from '../lib/parser'; import { needsFrontmatterProtection, getContentType, isValidPackageType, VALID_PACKAGE_TYPES, PackageType } from '../types'; interface PackageExclusions { @@ -481,6 +484,7 @@ const PACKABLE_EXTENSIONS = [ '.js', '.ts', '.mjs', '.cjs', // JavaScript/TypeScript '.py', '.sh', '.bash', // Scripts '.csv', '.xml', // Data files + '.xlsx', '.xls', '.docx', '.pdf', // Binary assets (pre-extracted to text during packaging) ]; /** Directories to always exclude */ @@ -859,6 +863,66 @@ export async function createPackageFromPrompdJson( }; } + // 6d. Validate that all relative file references in .prmd frontmatter are included in the package. + // This catches missing dependencies when an explicit files list is used in prompd.json. + if (!autoDiscovered) { + const parser = new PrompdParser(); + const missingDependencies: Array<{ prmd: string; field: string; ref: string; resolvedRelative: string }> = []; + const fileReferenceFields = ['system', 'context', 'task', 'user', 'assistant', 'response', 'output']; + const normalizedFilesToPackage = new Set(filesToPackage.map(f => f.replace(/\\/g, '/'))); + + for (const filePath of filesToPackage) { + if (!filePath.endsWith('.prmd')) continue; + + const fullPath = path.join(workspacePath, filePath); + try { + const content = await fs.readFile(fullPath, 'utf8'); + const parsed = parser.parseContent(content); + if (!parsed.metadata) continue; + + const prmdDir = path.dirname(filePath).replace(/\\/g, '/'); + + for (const field of fileReferenceFields) { + const fieldValue = (parsed.metadata as unknown as Record)[field]; + if (!fieldValue) continue; + + const refs: string[] = Array.isArray(fieldValue) + ? (fieldValue as unknown[]).filter((v): v is string => typeof v === 'string') + : typeof fieldValue === 'string' ? [fieldValue] : []; + + for (const ref of refs) { + if (!ref.startsWith('./') && !ref.startsWith('../')) continue; + + // Resolve relative to the .prmd file's directory (within workspace) + const resolvedRelative = path.posix.normalize( + prmdDir === '.' ? ref : `${prmdDir}/${ref}` + ).replace(/\\/g, '/'); + + if (!normalizedFilesToPackage.has(resolvedRelative)) { + // Check if the file actually exists on disk + const fullReferencedPath = path.join(workspacePath, resolvedRelative); + if (await fs.pathExists(fullReferencedPath)) { + missingDependencies.push({ prmd: filePath, field, ref, resolvedRelative }); + } + } + } + } + } catch { + // Parsing errors are already caught by step 6b — skip here + } + } + + if (missingDependencies.length > 0) { + const depList = missingDependencies.map(d => + ` ${d.prmd}: ${d.field}: "${d.ref}" (missing "${d.resolvedRelative}" from prompd.json files)` + ).join('\n'); + return { + success: false, + error: `Missing file dependencies — add these to your prompd.json "files" list:\n${depList}` + }; + } + } + // 6c. Validate all .pdflow workflow files for structural integrity and referenced files const workflowValidationErrors: Array<{ file: string; errors: string[] }> = []; @@ -1134,6 +1198,56 @@ content_type: ${contentType} return frontmatter + content; } +/** Binary asset extensions that must be pre-extracted to text during packaging */ +const BINARY_ASSET_EXTENSIONS = new Set(['.xlsx', '.xls', '.docx', '.pdf']); + +/** + * Pre-extract a binary asset to text content for safe packaging. + * Returns { content, newExtension } or null if not a binary asset. + */ +async function extractBinaryAsset(fullPath: string): Promise<{ content: string; newExtension: string } | null> { + const ext = path.extname(fullPath).toLowerCase(); + if (!BINARY_ASSET_EXTENSIONS.has(ext)) return null; + + switch (ext) { + case '.xlsx': + case '.xls': { + const workbook = xlsx.readFile(fullPath); + const sheets: string[] = []; + for (const sheetName of workbook.SheetNames) { + const sheet = workbook.Sheets[sheetName]; + const csv = xlsx.utils.sheet_to_csv(sheet); + if (csv.trim()) { + sheets.push(`### Sheet: ${sheetName}\n\n\`\`\`csv\n${csv}\n\`\`\``); + } + } + return { content: sheets.join('\n\n'), newExtension: '.csv.txt' }; + } + case '.docx': { + const buffer = await fs.readFile(fullPath); + const result = await mammoth.extractRawText({ buffer }); + const maxSize = 1024 * 1024; + const text = result.value.length > maxSize + ? result.value.substring(0, maxSize) + '\n\n[Content truncated...]' + : result.value; + return { content: text, newExtension: '.txt' }; + } + case '.pdf': { + const buffer = await fs.readFile(fullPath); + const pdfModule = await import('pdf-parse'); + const pdfParseFn = (pdfModule as Record).default || pdfModule; + const data = await (pdfParseFn as (buf: Buffer, opts?: Record) => Promise<{ text: string }>)(buffer, { max: 100 }); + const maxSize = 1024 * 1024; + const text = data.text.length > maxSize + ? data.text.substring(0, maxSize) + '\n\n[Content truncated...]' + : data.text; + return { content: text, newExtension: '.txt' }; + } + default: + return null; + } +} + /** * Create package from specific file list (not directory walk) * The files array is written to the archive's prompd.json (not the filesystem) @@ -1147,6 +1261,41 @@ async function createPackageFromFiles( readmeFile?: string, ignorePatterns?: string[] ): Promise { + // Pre-process files: extract binary assets to text, apply frontmatter protection + const normalizedFiles = files.map(f => f.replace(/\\/g, '/')); + const fileHashes: Record = {}; + const fileContents: Array<{ zipPath: string; content: string | Buffer }> = []; + const pathRenames: Record = {}; + + for (const filePath of files) { + const fullPath = path.join(workspacePath, filePath); + let zipPath = filePath.replace(/\\/g, '/'); + + // Pre-extract binary assets to text for safe packaging + const extracted = await extractBinaryAsset(fullPath); + if (extracted) { + const baseName = path.basename(zipPath, path.extname(zipPath)); + const dirName = path.dirname(zipPath); + const newZipPath = (dirName === '.' ? '' : dirName + '/') + baseName + extracted.newExtension; + pathRenames[zipPath] = newZipPath; + zipPath = newZipPath; + fileHashes[zipPath] = createHash('sha256').update(extracted.content).digest('hex'); + fileContents.push({ zipPath, content: extracted.content }); + } else if (needsFrontmatterProtection(filePath)) { + const content = fs.readFileSync(fullPath, 'utf-8'); + const filename = path.basename(filePath); + const protectedContent = addContentFrontmatter(content, filename); + fileHashes[zipPath] = createHash('sha256').update(protectedContent).digest('hex'); + fileContents.push({ zipPath, content: protectedContent }); + } else { + const content = fs.readFileSync(fullPath); + fileHashes[zipPath] = createHash('sha256').update(content).digest('hex'); + fileContents.push({ zipPath, content }); + } + } + + const finalFiles = normalizedFiles.map(f => pathRenames[f] || f); + return new Promise((resolve, reject) => { const output = fs.createWriteStream(outputPath); const archive = archiver('zip', { zlib: { level: 9 } }); @@ -1156,40 +1305,13 @@ async function createPackageFromFiles( archive.pipe(output); - // Files array uses original paths (no transformation needed with frontmatter approach) - const normalizedFiles = files.map(f => f.replace(/\\/g, '/')); - - // Collect file contents and compute integrity hashes - const fileHashes: Record = {}; - const fileContents: Array<{ zipPath: string; content: string | Buffer }> = []; - - for (const filePath of files) { - const fullPath = path.join(workspacePath, filePath); - const zipPath = filePath.replace(/\\/g, '/'); - - // For code files, add frontmatter protection - if (needsFrontmatterProtection(filePath)) { - const content = fs.readFileSync(fullPath, 'utf-8'); - const filename = path.basename(filePath); - const protectedContent = addContentFrontmatter(content, filename); - // Hash the protected content (with frontmatter) - matches what's in archive - fileHashes[zipPath] = createHash('sha256').update(protectedContent).digest('hex'); - fileContents.push({ zipPath, content: protectedContent }); - } else { - // For non-code files, read content for hashing - const content = fs.readFileSync(fullPath); - fileHashes[zipPath] = createHash('sha256').update(content).digest('hex'); - fileContents.push({ zipPath, content }); - } - } - // Add prompd.json (inside the package) with files array and integrity hashes // This writes the files array to the archive only, not the filesystem const fullManifest = { ...manifest, main: mainFile, readme: readmeFile, - files: normalizedFiles, + files: finalFiles, integrity: { algorithm: 'sha256', files: fileHashes diff --git a/typescript/src/commands/run.ts b/typescript/src/commands/run.ts index 9d6ca72..e79935e 100644 --- a/typescript/src/commands/run.ts +++ b/typescript/src/commands/run.ts @@ -40,11 +40,14 @@ export function createRunCommand(): Command { .option('--timeout ', 'Command execution timeout in milliseconds - for .pdflow files', '30000') .action(async (file: string, options) => { try { + // Check if it's a package reference (e.g., @namespace/package@version/path/to/file.prmd) + const isPackageRef = file.startsWith('@'); + // Check file extension to determine execution path const ext = path.extname(file).toLowerCase(); - // Forward .pdflow files to workflow execution - if (ext === '.pdflow') { + // Forward .pdflow files to workflow execution (local files only) + if (!isPackageRef && ext === '.pdflow') { console.log(chalk.blue('Detected workflow file, forwarding to workflow executor...')); console.log(); return await executeWorkflowFile(file, options); @@ -53,7 +56,7 @@ export function createRunCommand(): Command { const executor = new PrompdExecutor(); // For .txt, .md, or no-extension files: wrap with frontmatter and run through the prmd pipeline - const isRawText = ext === '.txt' || ext === '.md' || ext === ''; + const isRawText = !isPackageRef && (ext === '.txt' || ext === '.md' || ext === ''); if (isRawText) { if (options.verbose) { console.log(chalk.gray(`Detected ${ext || 'no-extension'} file, wrapping as .prmd for execution...`)); @@ -266,7 +269,7 @@ async function executeWorkflowFile(file: string, options: any): Promise { headless: options.headless ?? true, trace: options.trace ?? false, onToolCall: toolCallHandler, - executePrompt: async (source: string, params: Record, provider?: string, model?: string) => { + executePrompt: async (source: string, params: Record, provider?: string, model?: string, temperature?: number, maxTokens?: number) => { // Handle both .prmd files and raw prompt text try { // Check if source is a file path (.prmd extension) @@ -275,7 +278,9 @@ async function executeWorkflowFile(file: string, options: any): Promise { const result = await prompdExecutor.execute(source, { provider: provider || defaultProvider, model: model || defaultModel, - params: params + params: params, + ...(temperature !== undefined && { temperature }), + ...(maxTokens !== undefined && { maxTokens }) }); return result.response || result.content || ''; } else { diff --git a/typescript/src/commands/workflow.ts b/typescript/src/commands/workflow.ts index bd822e6..c673c7e 100644 --- a/typescript/src/commands/workflow.ts +++ b/typescript/src/commands/workflow.ts @@ -96,7 +96,7 @@ export function createWorkflowCommand(): Command { headless: options.headless ?? true, trace: options.trace ?? false, onToolCall: toolCallHandler, - executePrompt: async (source: string, params: Record, provider?: string, model?: string) => { + executePrompt: async (source: string, params: Record, provider?: string, model?: string, temperature?: number, maxTokens?: number) => { // Handle both .prmd files and raw prompt text let tempFilePath: string | null = null; try { @@ -106,7 +106,9 @@ export function createWorkflowCommand(): Command { const result = await prompdExecutor.execute(source, { provider: provider || defaultProvider, model: model || defaultModel, - params: params + params: params, + ...(temperature !== undefined && { temperature }), + ...(maxTokens !== undefined && { maxTokens }) }); return result.response || result.content || ''; } else if (source.startsWith('raw:')) { @@ -133,7 +135,9 @@ export function createWorkflowCommand(): Command { const result = await prompdExecutor.execute(tempFilePath, { provider: provider || defaultProvider, model: model || defaultModel, - params: params + params: params, + ...(temperature !== undefined && { temperature }), + ...(maxTokens !== undefined && { maxTokens }) }); // Clean up temp file @@ -152,7 +156,9 @@ export function createWorkflowCommand(): Command { const result = await prompdExecutor.execute(tempFilePath, { provider: provider || defaultProvider, model: model || defaultModel, - params: params + params: params, + ...(temperature !== undefined && { temperature }), + ...(maxTokens !== undefined && { maxTokens }) }); // Clean up temp file diff --git a/typescript/src/lib/auth.ts b/typescript/src/lib/auth.ts index 557032e..44d7841 100644 --- a/typescript/src/lib/auth.ts +++ b/typescript/src/lib/auth.ts @@ -5,13 +5,11 @@ import * as crypto from 'crypto'; import * as jwt from 'jsonwebtoken'; -import * as bcrypt from 'bcrypt'; import { EventEmitter } from 'events'; export interface AuthConfig { jwtSecret: string; jwtExpiresIn: string; - bcryptRounds: number; oauth: { clientId: string; clientSecret: string; @@ -319,20 +317,6 @@ export class AuthManager extends EventEmitter { return this.users.get(userId) || null; } - /** - * Hash password securely - */ - async hashPassword(password: string): Promise { - return await bcrypt.hash(password, this.config.bcryptRounds); - } - - /** - * Verify password - */ - async verifyPassword(password: string, hash: string): Promise { - return await bcrypt.compare(password, hash); - } - /** * Destroy session */ @@ -458,7 +442,6 @@ export class AuthMiddleware { export const createDefaultAuthConfig = (): AuthConfig => ({ jwtSecret: '', jwtExpiresIn: '24h', - bcryptRounds: 12, oauth: { clientId: '', clientSecret: '', diff --git a/typescript/src/lib/compiler/index.ts b/typescript/src/lib/compiler/index.ts index 27831c2..3463279 100644 --- a/typescript/src/lib/compiler/index.ts +++ b/typescript/src/lib/compiler/index.ts @@ -133,4 +133,4 @@ export { SectionOverrideProcessor } from './section-override'; export { MarkdownFormatter } from './formatters/markdown'; export { OpenAIFormatter } from './formatters/openai'; export { AnthropicFormatter } from './formatters/anthropic'; -export { findProjectRoot, resolvePackage, resolvePackageFile, isPackageInstalled, parsePackageReference, getLocalBaseDir, getGlobalBaseDir } from './package-resolver'; +export { findProjectRoot, resolvePackage, resolvePackageFile, isPackageInstalled, parsePackageReference, parsePackageReferenceWithPath, getLocalBaseDir, getGlobalBaseDir } from './package-resolver'; diff --git a/typescript/src/lib/compiler/package-resolver.ts b/typescript/src/lib/compiler/package-resolver.ts index b04b231..301a397 100644 --- a/typescript/src/lib/compiler/package-resolver.ts +++ b/typescript/src/lib/compiler/package-resolver.ts @@ -8,6 +8,7 @@ import * as path from 'path'; import * as fs from 'fs-extra'; import * as os from 'os'; +import semver from 'semver'; import { RegistryClient } from '../registry'; import { SecurityError } from '../errors'; import { IFileSystem, MemoryFileSystem } from './file-system'; @@ -60,8 +61,10 @@ export async function resolvePackage( throw new SecurityError(`Invalid package reference format: ${packageRef}`); } - // Parse package reference + // Parse package reference (strips file path suffix automatically) const { name, version } = parsePackageReference(packageRef); + // Build clean package ref without file path for registry operations + const cleanRef = `${name}@${version}`; // Handle in-memory file system if (fileSystem instanceof MemoryFileSystem) { @@ -82,7 +85,7 @@ export async function resolvePackage( return await registry.downloadPackageBuffer(pkgName, pkgVersion); }; - await fileSystem.addPackageFromRegistry(packageRef, downloadFn); + await fileSystem.addPackageFromRegistry(cleanRef, downloadFn); return packagePath; } catch (error) { throw new Error(`Failed to load package into memory ${packageRef}: ${error instanceof Error ? error.message : String(error)}`); @@ -141,7 +144,7 @@ export async function resolvePackage( try { // Create registry client with optional URL override const registry = new RegistryClient(resolvedRegistryUrl ? { registryUrl: resolvedRegistryUrl } : undefined); - await registry.install(packageRef, { skipCache: false, workspaceRoot }); + await registry.install(cleanRef, { skipCache: false, workspaceRoot }); // After installation, check type directories again (new install location) for (const typeDir of typeDirs) { @@ -163,17 +166,23 @@ export async function resolvePackage( /** * Parse package reference into name and version. + * Strips any file path suffix before parsing. */ export function parsePackageReference(packageRef: string): { name: string; version: string; scope?: string } { + // Strip file path suffix if present (e.g., @ns/pkg@1.0.0/path/to/file.prmd -> @ns/pkg@1.0.0) + const stripped = stripFilePath(packageRef); + // Format: @namespace/package@version or @namespace/package (latest) - const match = packageRef.match(/^(@[\w.-]+\/[\w.-]+)@?([\w.-]+)?$/); + const match = stripped.match(/^(@[\w.-]+\/[\w.-]+)@?([\w.-]+)?$/); if (!match) { throw new Error(`Invalid package reference format: ${packageRef}`); } const name = match[1]; - const version = match[2] || 'latest'; + const rawVersion = match[2] || 'latest'; + // Clean semver version (strips 'v' prefix: v0.0.1 -> 0.0.1) + const version = semver.valid(rawVersion) || rawVersion; // Extract scope const scopeMatch = name.match(/^(@[\w.-]+)\//); @@ -182,25 +191,77 @@ export function parsePackageReference(packageRef: string): { name: string; versi return { name, version, scope }; } +/** + * Parse a package reference that may include a file path within the package. + * Format: @namespace/package@version/path/to/file.prmd + * + * @returns Package name, version, scope, and optional filePath within the package + */ +export function parsePackageReferenceWithPath(packageRef: string): { name: string; version: string; scope?: string; filePath?: string } { + const { name, version, scope } = parsePackageReference(packageRef); + + // Extract the file path portion after @namespace/package@version + const stripped = stripFilePath(packageRef); + const remainder = packageRef.slice(stripped.length); + const filePath = remainder.length > 1 ? remainder.slice(1) : undefined; // Remove leading / + + return { name, version, scope, filePath }; +} + +/** + * Strip a file path suffix from a package reference. + * @example "@ns/pkg@1.0.0/prompts/file.prmd" -> "@ns/pkg@1.0.0" + * @example "@ns/pkg@1.0.0" -> "@ns/pkg@1.0.0" + * @example "@ns/pkg" -> "@ns/pkg" + */ +function stripFilePath(packageRef: string): string { + // Match @scope/name then optionally @version, stopping before any /path + const match = packageRef.match(/^(@[\w.-]+\/[\w.-]+(?:@[\w.-]+)?)/); + return match ? match[1] : packageRef; +} + /** * Validate package reference format. + * Accepts optional file path suffix: @namespace/package@version/path/to/file.prmd */ export function isValidPackageReference(packageRef: string): boolean { - // Security: Ensure package ref follows npm-style scoped package format - // Format: @namespace/package@version - const pattern = /^@[\w.-]+\/[\w.-]+(@[\w.-]+)?$/; - - if (!pattern.test(packageRef)) { + // Security: Check for null bytes + if (packageRef.includes('\0')) { return false; } - // Security: Check for path traversal attempts - if (packageRef.includes('..') || packageRef.includes('//')) { + // Strip file path suffix for base validation + const base = stripFilePath(packageRef); + + // Security: Ensure base follows scoped package format + const pattern = /^@[\w.-]+\/[\w.-]+(@[\w.-]+)?$/; + if (!pattern.test(base)) { return false; } - // Security: Check for null bytes - if (packageRef.includes('\0')) { + // If there's a file path suffix, validate it + const remainder = packageRef.slice(base.length); + if (remainder.length > 0) { + // Must start with / + if (!remainder.startsWith('/')) { + return false; + } + + const filePath = remainder.slice(1); + + // Security: Check for path traversal attempts + if (filePath.includes('..') || filePath.includes('//')) { + return false; + } + + // Security: Only allow safe characters in file paths + if (!/^[\w./\-]+$/.test(filePath)) { + return false; + } + } + + // Security: Check for path traversal in the base reference + if (base.includes('..') || base.includes('//')) { return false; } diff --git a/typescript/src/lib/compiler/pipeline.ts b/typescript/src/lib/compiler/pipeline.ts index e5097f5..2f06acd 100644 --- a/typescript/src/lib/compiler/pipeline.ts +++ b/typescript/src/lib/compiler/pipeline.ts @@ -92,17 +92,25 @@ export class CompilerPipeline { // Check if it's a package reference (starts with @) if (source.startsWith('@')) { // Import package resolver - const { resolvePackage } = await import('./package-resolver'); + const { resolvePackage, parsePackageReferenceWithPath, resolvePackageFile } = await import('./package-resolver'); try { - // Pass options to resolvePackage for proper package resolution + // Check if the reference includes a specific file path + const { filePath } = parsePackageReferenceWithPath(source); + + // resolvePackage handles stripping the file path internally const packagePath = await resolvePackage(source, { fileSystem, registryUrl, workspaceRoot }); - // Find the main .prmd file in the package + // If a specific file was requested, resolve it within the package + if (filePath) { + return resolvePackageFile(packagePath, filePath); + } + + // No specific file — find the main .prmd file in the package const prmdFiles = await this.findPromdFiles(packagePath, fileSystem); if (prmdFiles.length === 0) { diff --git a/typescript/src/lib/compiler/stages/template.ts b/typescript/src/lib/compiler/stages/template.ts index 9dc116a..6fa1d29 100644 --- a/typescript/src/lib/compiler/stages/template.ts +++ b/typescript/src/lib/compiler/stages/template.ts @@ -462,6 +462,25 @@ export class TemplateProcessingStage implements CompilerStage { const parentFileContent = await fs.readFile(parentFile); const parentData = parser.parseContent(parentFileContent); + // Validate that all file references in the parent's metadata actually exist on disk. + // AssetExtractionStage only processes the child's metadata, so the parent's file + // references (system:, context:, etc.) must be validated here. + await this.validateParentFileReferences(context, parentData.metadata as unknown as Record, fs, parentFile); + + // If validation added errors, stop inheritance processing + if (context.hasErrors()) { + return content; + } + + // Resolve relative {% include %} paths in parent content to absolute paths. + // This is necessary because the parent's includes are relative to the parent's + // directory, but after merging into the child, Nunjucks will resolve them + // relative to the child's directory (which may be different). + if (parentData.content) { + const parentDir = fs.dirname(parentFile); + parentData.content = this.resolveIncludePaths(parentData.content, parentDir); + } + // Get overrides from child metadata const overrides = context.metadata?.override || {}; @@ -526,6 +545,25 @@ export class TemplateProcessingStage implements CompilerStage { context.metadata.parameters = []; } context.metadata.parameters.push(parentParam); + + // Apply default for inherited param (semantic stage already ran) + if (!(parentParam.name in context.parameters) && parentParam.default !== undefined) { + let value = parentParam.default; + // Coerce string defaults to their declared type (mirrors SemanticAnalysisStage) + if (typeof value === 'string') { + const t = parentParam.type; + if (t === 'json' || t === 'object' || t === 'array') { + try { value = JSON.parse(value); } catch { /* keep as string */ } + } else if (t === 'boolean') { + if (value.toLowerCase() === 'true') value = true; + else if (value.toLowerCase() === 'false') value = false; + } else if (t === 'integer' || t === 'number' || t === 'float') { + const n = Number(value); + if (!isNaN(n)) value = n; + } + } + context.parameters[parentParam.name] = value; + } } } } @@ -833,6 +871,25 @@ export class TemplateProcessingStage implements CompilerStage { return content; } + /** + * Resolve relative {% include %} paths in content to absolute paths. + * This ensures that when parent content is merged into a child file, + * the includes still resolve correctly relative to the parent's directory. + */ + private resolveIncludePaths(content: string, baseDir: string): string { + // Match {% include "path" %} and {% include 'path' %} with relative paths + // Captures: (prefix)(quote)(relativePath)(quote)(suffix) to reconstruct safely + return content.replace( + /(\{%[-\s]*include\s+)(["'])(\.[^"']+)\2(\s*[-]?%\})/g, + (_match, prefix, quote, relativePath, suffix) => { + const absolutePath = path.resolve(baseDir, relativePath); + // Use forward slashes for cross-platform Nunjucks compatibility + const normalizedPath = absolutePath.replace(/\\/g, '/'); + return `${prefix}${quote}${normalizedPath}${quote}${suffix}`; + } + ); + } + /** * Register custom filters on a Nunjucks environment. */ @@ -1137,6 +1194,51 @@ export class TemplateProcessingStage implements CompilerStage { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } + /** + * Validate that all file references in a parent .prmd's metadata actually exist. + * Called during inheritance processing because AssetExtractionStage only runs on + * the child file's metadata — the parent is only parsed, never fully compiled. + */ + private async validateParentFileReferences( + context: CompilationContext, + metadata: Record | null | undefined, + fs: CompilationContext['fileSystem'], + parentFile: string + ): Promise { + if (!metadata) return; + + const metadataAsRecord = metadata as Record; + const parentDir = fs.dirname(parentFile); + const fileFields = ['system', 'task', 'user', 'assistant', 'response', 'output', 'context']; + + for (const field of fileFields) { + const fieldValue = metadataAsRecord[field]; + if (!fieldValue) continue; + + const refs: string[] = Array.isArray(fieldValue) + ? (fieldValue as unknown[]).filter((v): v is string => typeof v === 'string') + : typeof fieldValue === 'string' ? [fieldValue] : []; + + for (const ref of refs) { + if (!ref.startsWith('./') && !ref.startsWith('../')) continue; + + const resolvedPath = fs.resolve(parentDir, ref); + const exists = await Promise.resolve(fs.exists(resolvedPath)); + + if (!exists) { + const location = context.findLocation(/inherits:/); + context.addDiagnostic({ + message: `Inherited file "${path.basename(parentFile)}" references missing ${field} file: "${ref}" (resolved to: ${resolvedPath})`, + severity: 'error', + source: 'template', + code: 'INHERITED_METADATA_FILE_NOT_FOUND', + ...(location || {}) + }); + } + } + } + } + getName(): string { return 'Template Processing'; } diff --git a/typescript/src/lib/config.ts b/typescript/src/lib/config.ts index 18674a2..b9d9efe 100644 --- a/typescript/src/lib/config.ts +++ b/typescript/src/lib/config.ts @@ -324,9 +324,9 @@ export class ConfigManager { private ensureDefaultRegistries(config: Config): void { // Always ensure prompdhub is available (unless explicitly removed) - if (!config.registry.registries.prmdhub) { - config.registry.registries.prmdhub = { - url: 'https://registry.prmdhub.ai', + if (!config.registry.registries.prompdhub) { + config.registry.registries.prompdhub = { + url: 'https://registry.prompdhub.ai', token: undefined, username: undefined }; @@ -344,9 +344,9 @@ export class ConfigManager { const legacyToken = config.apiKeys.prmd; // Move to registry structure if prompdhub doesn't have a token - if (!config.registry.registries.prmdhub?.token) { - config.registry.registries.prmdhub = { - ...config.registry.registries.prmdhub, + if (!config.registry.registries.prompdhub?.token) { + config.registry.registries.prompdhub = { + ...config.registry.registries.prompdhub, token: legacyToken }; } diff --git a/typescript/src/lib/executor.ts b/typescript/src/lib/executor.ts index 641077a..47f3ac1 100644 --- a/typescript/src/lib/executor.ts +++ b/typescript/src/lib/executor.ts @@ -28,23 +28,23 @@ export class PrompdExecutor { } } - // Compile through the full 6-stage pipeline - // Handles: parsing, parameter validation, Nunjucks templates ({% for %}, filters), - // inheritance, context resolution, includes, section overrides, and output formatting + // Compile through the full 6-stage pipeline and capture metadata for execution hints const compiler = new PrompdCompiler(); // Make params available as both {{ param_name }} and {{ workflow.param_name }} - // The workflow. prefix is a namespace convention used in .prmd templates within workflows const compilationParams = { ...params, workflow: { ...params } }; - let content = await compiler.compile(filePath, { - outputFormat: 'markdown', + const compilationOptions = { + outputFormat: 'markdown' as const, parameters: compilationParams, verbose: options.verbose, registryUrl: options.registryUrl, workspaceRoot: options.workspaceRoot, fileSystem: options.fileSystem - }); + }; + + const compilationContext = await compiler.compileWithContext(filePath, compilationOptions); + let content = (compilationContext.compiledResult as string) || ''; // Apply metadata overrides if provided (CLI --meta-system/context/user flags) if (options.metaSystem || options.metaContext || options.metaUser) { @@ -58,20 +58,33 @@ export class PrompdExecutor { ); } + // Priority chain for execution parameters: + // 1. Hardcoded fallback + // 2. Frontmatter hints (provider, model, temperature, max_tokens) + // 3. options (CLI flags / node properties) — highest priority + const frontmatter = compilationContext.metadata; + const resolvedProvider = options.provider || frontmatter?.provider || config.defaultProvider || 'openai'; + const resolvedModel = options.model || frontmatter?.model || config.defaultModel || 'gpt-4o'; + const resolvedTemperature = options.temperature ?? frontmatter?.temperature ?? 0.7; + const resolvedMaxTokens = options.maxTokens ?? frontmatter?.max_tokens ?? 4096; + // Get API key - const apiKey = options.apiKey || this.configManager.getApiKey(options.provider, config); - if (!apiKey && options.provider !== 'ollama') { - throw new Error(`API key required for provider ${options.provider}`); + const apiKey = options.apiKey || this.configManager.getApiKey(resolvedProvider, config); + if (!apiKey && resolvedProvider !== 'ollama') { + throw new Error(`API key required for provider ${resolvedProvider}`); } if (options.verbose) { - console.log(`Executing ${filePath} with ${options.provider}/${options.model}`); + console.log(`Executing ${filePath} with ${resolvedProvider}/${resolvedModel}`); + if (frontmatter?.temperature !== undefined || frontmatter?.max_tokens !== undefined) { + console.log(`Frontmatter hints: temperature=${frontmatter.temperature}, max_tokens=${frontmatter.max_tokens}`); + } console.log('Parameters:', params); console.log(''); } // Execute compiled content with LLM - return await this.callLLM(options.provider, options.model, content, apiKey); + return await this.callLLM(resolvedProvider, resolvedModel, content, apiKey, resolvedTemperature, resolvedMaxTokens); } /** @@ -144,7 +157,14 @@ export class PrompdExecutor { ); } - private async callLLM(providerName: string, model: string, content: string, apiKey?: string): Promise { + private async callLLM( + providerName: string, + model: string, + content: string, + apiKey?: string, + temperature = 0.7, + maxTokens = 4096 + ): Promise { const customConfig = await this.resolveCustomProvider(providerName); const provider = createProvider(providerName, customConfig); @@ -152,8 +172,8 @@ export class PrompdExecutor { prompt: content, model, apiKey: apiKey || '', - maxTokens: 4096, - temperature: 0.7 + maxTokens, + temperature }); if (!result.success) { diff --git a/typescript/src/lib/index.ts b/typescript/src/lib/index.ts index c037f62..9c3b815 100644 --- a/typescript/src/lib/index.ts +++ b/typescript/src/lib/index.ts @@ -250,6 +250,23 @@ export { NoOpBackend } from './memoryBackend'; +// Test harness interface (for pluggable test frameworks) +export { + registerTestHarness, + getTestHarness +} from './testHarness'; +export type { + TestHarness, + TestHarnessResult, + TestHarnessSuiteResult, + TestHarnessTestResult, + TestHarnessAssertionResult, + TestHarnessSummary, + TestHarnessOptions, + TestHarnessProgressEvent, + TestHarnessProgressCallback +} from './testHarness'; + // Version information (from package.json — single source of truth) import pkg from '../../package.json'; export const VERSION = pkg.version; diff --git a/typescript/src/lib/providers/base.ts b/typescript/src/lib/providers/base.ts index 2344e3e..c089683 100644 --- a/typescript/src/lib/providers/base.ts +++ b/typescript/src/lib/providers/base.ts @@ -13,6 +13,7 @@ import type { ExecutionResult, StreamChunk, ModelInfo, + ModelCapabilities, TokenUsage, ProviderEntry } from './types' @@ -41,6 +42,24 @@ export abstract class BaseProvider implements IExecutionProvider { */ abstract stream(request: ExecutionRequest): AsyncGenerator + /** + * Get capabilities for a specific model. + * Matches by exact ID first, then by prefix (e.g., 'gpt-4.1-nano-2025-04-14' matches 'gpt-4.1-nano'). + */ + protected getModelCapabilities(modelId: string): ModelCapabilities { + if (!this.config.models) return {} + + // Exact match first + const exact = this.config.models.find(m => m.id === modelId) + if (exact?.capabilities) return exact.capabilities + + // Prefix match — model IDs often have date suffixes (e.g., 'gpt-4.1-nano-2025-04-14') + const prefixMatch = this.config.models.find(m => modelId.startsWith(m.id)) + if (prefixMatch?.capabilities) return prefixMatch.capabilities + + return {} + } + /** * List available models from config */ @@ -121,6 +140,7 @@ export class OpenAICompatibleProvider extends BaseProvider { async execute(request: ExecutionRequest): Promise { const startTime = Date.now() + const caps = this.getModelCapabilities(request.model) try { // Use the Responses API for image generation (OpenAI only, not other compatible providers) @@ -135,10 +155,16 @@ export class OpenAICompatibleProvider extends BaseProvider { ? (request.systemPrompt ? `${request.systemPrompt}\n\nRespond with valid JSON.` : 'Respond with valid JSON.') : request.systemPrompt - if (systemPrompt) { + // Some reasoning models don't accept system messages (o1, o3, etc.) + if (systemPrompt && !caps.noSystemMessage) { messages.push({ role: 'system', content: systemPrompt }) + } else if (systemPrompt && caps.noSystemMessage) { + // Prepend system content to user message as a workaround + messages.push({ role: 'user', content: `${systemPrompt}\n\n${request.prompt}` }) + } + if (!caps.noSystemMessage || !systemPrompt) { + messages.push({ role: 'user', content: request.prompt }) } - messages.push({ role: 'user', content: request.prompt }) const body: Record = { model: request.model, @@ -147,9 +173,16 @@ export class OpenAICompatibleProvider extends BaseProvider { } if (request.maxTokens) { - body.max_tokens = request.maxTokens + // Use max_completion_tokens for models that require it (gpt-4.1+, o1, o3, etc.) + if (caps.useMaxCompletionTokens) { + body.max_completion_tokens = request.maxTokens + } else { + body.max_tokens = request.maxTokens + } } - if (request.temperature !== undefined) { + + // Some reasoning models don't accept temperature + if (request.temperature !== undefined && !caps.noTemperature) { body.temperature = request.temperature } @@ -206,6 +239,7 @@ export class OpenAICompatibleProvider extends BaseProvider { } async *stream(request: ExecutionRequest): AsyncGenerator { + const caps = this.getModelCapabilities(request.model) const messages: Array<{ role: string; content: string }> = [] // For JSON mode, ensure "json" appears in the system prompt (OpenAI requirement) @@ -213,10 +247,14 @@ export class OpenAICompatibleProvider extends BaseProvider { ? (request.systemPrompt ? `${request.systemPrompt}\n\nRespond with valid JSON.` : 'Respond with valid JSON.') : request.systemPrompt - if (systemPrompt) { + if (systemPrompt && !caps.noSystemMessage) { messages.push({ role: 'system', content: systemPrompt }) + } else if (systemPrompt && caps.noSystemMessage) { + messages.push({ role: 'user', content: `${systemPrompt}\n\n${request.prompt}` }) + } + if (!caps.noSystemMessage || !systemPrompt) { + messages.push({ role: 'user', content: request.prompt }) } - messages.push({ role: 'user', content: request.prompt }) const body: Record = { model: request.model, @@ -225,9 +263,13 @@ export class OpenAICompatibleProvider extends BaseProvider { } if (request.maxTokens) { - body.max_tokens = request.maxTokens + if (caps.useMaxCompletionTokens) { + body.max_completion_tokens = request.maxTokens + } else { + body.max_tokens = request.maxTokens + } } - if (request.temperature !== undefined) { + if (request.temperature !== undefined && !caps.noTemperature) { body.temperature = request.temperature } diff --git a/typescript/src/lib/providers/index.ts b/typescript/src/lib/providers/index.ts index 20d455a..9e51141 100644 --- a/typescript/src/lib/providers/index.ts +++ b/typescript/src/lib/providers/index.ts @@ -13,6 +13,7 @@ export type { StreamChunk, TokenUsage, ModelInfo, + ModelCapabilities, ProviderConfig, ProviderEntry, GenerationMode diff --git a/typescript/src/lib/providers/types.ts b/typescript/src/lib/providers/types.ts index 3ce107e..6097699 100644 --- a/typescript/src/lib/providers/types.ts +++ b/typescript/src/lib/providers/types.ts @@ -70,6 +70,31 @@ export interface StreamChunk { thinking?: string } +/** + * Model capability flags — declare what a model supports so the provider + * layer can build API requests correctly without per-model branching. + */ +export interface ModelCapabilities { + /** Model uses 'max_completion_tokens' instead of 'max_tokens' (OpenAI o1+, gpt-4.1+) */ + useMaxCompletionTokens?: boolean + /** Model supports extended thinking / reasoning (Claude Sonnet/Opus, o1/o3) */ + supportsThinking?: boolean + /** Model supports vision/image input */ + supportsVision?: boolean + /** Model supports tool/function calling */ + supportsTools?: boolean + /** Model supports image generation output */ + supportsImageGeneration?: boolean + /** Model supports JSON mode / structured output */ + supportsJsonMode?: boolean + /** Model supports streaming */ + supportsStreaming?: boolean + /** Model does NOT accept a temperature parameter */ + noTemperature?: boolean + /** Model does NOT accept a system message (some reasoning models) */ + noSystemMessage?: boolean +} + /** * Model information */ @@ -78,6 +103,7 @@ export interface ModelInfo { name: string contextWindow?: number maxOutput?: number + capabilities?: ModelCapabilities } /** @@ -152,13 +178,20 @@ export const KNOWN_PROVIDERS: Record = { consoleUrl: 'https://platform.openai.com/api-keys', isOpenAICompatible: true, models: [ - { id: 'gpt-4o', name: 'GPT-4o', contextWindow: 128000, maxOutput: 16384 }, - { id: 'gpt-4o-mini', name: 'GPT-4o Mini', contextWindow: 128000, maxOutput: 16384 }, - { id: 'gpt-4-turbo', name: 'GPT-4 Turbo', contextWindow: 128000, maxOutput: 4096 }, - { id: 'gpt-4', name: 'GPT-4', contextWindow: 8192, maxOutput: 4096 }, - { id: 'gpt-3.5-turbo', name: 'GPT-3.5 Turbo', contextWindow: 16385, maxOutput: 4096 }, - { id: 'o1-preview', name: 'o1 Preview', contextWindow: 128000, maxOutput: 32768 }, - { id: 'o1-mini', name: 'o1 Mini', contextWindow: 128000, maxOutput: 65536 } + { id: 'gpt-4o', name: 'GPT-4o', contextWindow: 128000, maxOutput: 16384, capabilities: { supportsVision: true, supportsTools: true, supportsJsonMode: true, supportsImageGeneration: true } }, + { id: 'gpt-4o-mini', name: 'GPT-4o Mini', contextWindow: 128000, maxOutput: 16384, capabilities: { supportsVision: true, supportsTools: true, supportsJsonMode: true } }, + { id: 'gpt-4-turbo', name: 'GPT-4 Turbo', contextWindow: 128000, maxOutput: 4096, capabilities: { supportsVision: true, supportsTools: true, supportsJsonMode: true } }, + { id: 'gpt-4', name: 'GPT-4', contextWindow: 8192, maxOutput: 4096, capabilities: { supportsTools: true } }, + { id: 'gpt-4.1', name: 'GPT-4.1', contextWindow: 1047576, maxOutput: 32768, capabilities: { useMaxCompletionTokens: true, supportsVision: true, supportsTools: true, supportsJsonMode: true } }, + { id: 'gpt-4.1-mini', name: 'GPT-4.1 Mini', contextWindow: 1047576, maxOutput: 32768, capabilities: { useMaxCompletionTokens: true, supportsVision: true, supportsTools: true, supportsJsonMode: true } }, + { id: 'gpt-4.1-nano', name: 'GPT-4.1 Nano', contextWindow: 1047576, maxOutput: 32768, capabilities: { useMaxCompletionTokens: true, supportsVision: true, supportsTools: true, supportsJsonMode: true } }, + { id: 'gpt-3.5-turbo', name: 'GPT-3.5 Turbo', contextWindow: 16385, maxOutput: 4096, capabilities: { supportsTools: true } }, + { id: 'o1', name: 'o1', contextWindow: 200000, maxOutput: 100000, capabilities: { useMaxCompletionTokens: true, supportsThinking: true, noTemperature: true, noSystemMessage: true } }, + { id: 'o1-mini', name: 'o1 Mini', contextWindow: 128000, maxOutput: 65536, capabilities: { useMaxCompletionTokens: true, supportsThinking: true, noTemperature: true, noSystemMessage: true } }, + { id: 'o1-preview', name: 'o1 Preview', contextWindow: 128000, maxOutput: 32768, capabilities: { useMaxCompletionTokens: true, supportsThinking: true, noTemperature: true, noSystemMessage: true } }, + { id: 'o3', name: 'o3', contextWindow: 200000, maxOutput: 100000, capabilities: { useMaxCompletionTokens: true, supportsThinking: true, noTemperature: true, noSystemMessage: true } }, + { id: 'o3-mini', name: 'o3 Mini', contextWindow: 200000, maxOutput: 100000, capabilities: { useMaxCompletionTokens: true, supportsThinking: true, noTemperature: true, noSystemMessage: true } }, + { id: 'o4-mini', name: 'o4 Mini', contextWindow: 200000, maxOutput: 100000, capabilities: { useMaxCompletionTokens: true, supportsThinking: true, noTemperature: true, noSystemMessage: true } } ] }, anthropic: { @@ -169,11 +202,13 @@ export const KNOWN_PROVIDERS: Record = { consoleUrl: 'https://console.anthropic.com/settings/keys', isOpenAICompatible: false, models: [ - { id: 'claude-3-5-sonnet-20241022', name: 'Claude 3.5 Sonnet', contextWindow: 200000, maxOutput: 8192 }, - { id: 'claude-3-5-haiku-20241022', name: 'Claude 3.5 Haiku', contextWindow: 200000, maxOutput: 8192 }, - { id: 'claude-3-opus-20240229', name: 'Claude 3 Opus', contextWindow: 200000, maxOutput: 4096 }, - { id: 'claude-3-sonnet-20240229', name: 'Claude 3 Sonnet', contextWindow: 200000, maxOutput: 4096 }, - { id: 'claude-3-haiku-20240307', name: 'Claude 3 Haiku', contextWindow: 200000, maxOutput: 4096 } + { id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4', contextWindow: 200000, maxOutput: 16384, capabilities: { supportsThinking: true, supportsVision: true, supportsTools: true } }, + { id: 'claude-opus-4-20250514', name: 'Claude Opus 4', contextWindow: 200000, maxOutput: 16384, capabilities: { supportsThinking: true, supportsVision: true, supportsTools: true } }, + { id: 'claude-3-5-sonnet-20241022', name: 'Claude 3.5 Sonnet', contextWindow: 200000, maxOutput: 8192, capabilities: { supportsThinking: true, supportsVision: true, supportsTools: true } }, + { id: 'claude-3-5-haiku-20241022', name: 'Claude 3.5 Haiku', contextWindow: 200000, maxOutput: 8192, capabilities: { supportsVision: true, supportsTools: true } }, + { id: 'claude-3-opus-20240229', name: 'Claude 3 Opus', contextWindow: 200000, maxOutput: 4096, capabilities: { supportsVision: true, supportsTools: true } }, + { id: 'claude-3-sonnet-20240229', name: 'Claude 3 Sonnet', contextWindow: 200000, maxOutput: 4096, capabilities: { supportsVision: true, supportsTools: true } }, + { id: 'claude-3-haiku-20240307', name: 'Claude 3 Haiku', contextWindow: 200000, maxOutput: 4096, capabilities: { supportsVision: true, supportsTools: true } } ] }, google: { @@ -184,10 +219,11 @@ export const KNOWN_PROVIDERS: Record = { consoleUrl: 'https://aistudio.google.com/app/apikey', isOpenAICompatible: false, models: [ - { id: 'gemini-1.5-pro', name: 'Gemini 1.5 Pro', contextWindow: 2097152, maxOutput: 8192 }, - { id: 'gemini-1.5-flash', name: 'Gemini 1.5 Flash', contextWindow: 1048576, maxOutput: 8192 }, - { id: 'gemini-2.0-flash-exp', name: 'Gemini 2.0 Flash', contextWindow: 1048576, maxOutput: 8192 }, - { id: 'gemini-pro', name: 'Gemini Pro', contextWindow: 32760, maxOutput: 8192 } + { id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', contextWindow: 1048576, maxOutput: 65536, capabilities: { supportsThinking: true, supportsVision: true, supportsTools: true, supportsJsonMode: true } }, + { id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', contextWindow: 1048576, maxOutput: 65536, capabilities: { supportsThinking: true, supportsVision: true, supportsTools: true, supportsJsonMode: true } }, + { id: 'gemini-2.0-flash', name: 'Gemini 2.0 Flash', contextWindow: 1048576, maxOutput: 8192, capabilities: { supportsVision: true, supportsTools: true, supportsJsonMode: true } }, + { id: 'gemini-1.5-pro', name: 'Gemini 1.5 Pro', contextWindow: 2097152, maxOutput: 8192, capabilities: { supportsVision: true, supportsTools: true, supportsJsonMode: true } }, + { id: 'gemini-1.5-flash', name: 'Gemini 1.5 Flash', contextWindow: 1048576, maxOutput: 8192, capabilities: { supportsVision: true, supportsJsonMode: true } } ] }, groq: { diff --git a/typescript/src/lib/registry.ts b/typescript/src/lib/registry.ts index 6421f5d..50bb6de 100644 --- a/typescript/src/lib/registry.ts +++ b/typescript/src/lib/registry.ts @@ -673,8 +673,9 @@ export class RegistryClient extends EventEmitter { return packageInfo.version; } - if (semver.valid(versionSpec)) { - return versionSpec; + const cleaned = semver.valid(versionSpec); + if (cleaned) { + return cleaned; } // Resolve version range diff --git a/typescript/src/lib/testHarness.ts b/typescript/src/lib/testHarness.ts new file mode 100644 index 0000000..404da74 --- /dev/null +++ b/typescript/src/lib/testHarness.ts @@ -0,0 +1,139 @@ +/** + * TestHarness — Standard interface for prompt test frameworks. + * + * @prompd/cli defines this interface as the contract for test execution. + * Any test harness (@prompd/test, third-party, etc.) implements it and + * registers via `registerTestHarness()`. The CLI's `prompd test` command + * delegates to whichever harness is registered. + * + * This avoids circular dependencies — the CLI doesn't depend on any + * specific test framework, but any test framework can plug in. + * + * @example + * ```typescript + * import { registerTestHarness } from '@prompd/cli'; + * import { TestRunner } from '@prompd/test'; + * + * const cli = await import('@prompd/cli'); + * registerTestHarness(new TestRunner(cli)); + * ``` + */ + +// --- Result types --- + +export interface TestHarnessResult { + suites: TestHarnessSuiteResult[]; + summary: TestHarnessSummary; +} + +export interface TestHarnessSuiteResult { + suite: string; + testFilePath: string; + results: TestHarnessTestResult[]; +} + +export interface TestHarnessTestResult { + suite: string; + testName: string; + status: 'pass' | 'fail' | 'error' | 'skip'; + duration: number; + assertions: TestHarnessAssertionResult[]; + output?: string; + compiledInput?: string; + error?: string; +} + +export interface TestHarnessAssertionResult { + evaluator: string; + check?: string; + status: 'pass' | 'fail' | 'error' | 'skip'; + reason?: string; + duration: number; +} + +export interface TestHarnessSummary { + total: number; + passed: number; + failed: number; + errors: number; + skipped: number; + duration: number; +} + +// --- Options --- + +export interface TestHarnessOptions { + evaluators?: string[]; + noLlm?: boolean; + reporter?: string; + failFast?: boolean; + runAll?: boolean; + verbose?: boolean; + workspaceRoot?: string; + registryUrl?: string; + provider?: string; + model?: string; +} + +// --- Progress --- + +export interface TestHarnessProgressEvent { + type: 'suite_start' | 'test_start' | 'test_complete' | 'suite_complete' | 'assertion_complete'; + suite: string; + testName?: string; + testCount?: number; + result?: TestHarnessTestResult; + assertion?: TestHarnessAssertionResult; +} + +export type TestHarnessProgressCallback = (event: TestHarnessProgressEvent) => void; + +// --- Interface --- + +export interface TestHarness { + /** + * Run tests for a target path (file or directory). + * Returns structured results. + */ + run( + targetPath: string, + options?: TestHarnessOptions, + onProgress?: TestHarnessProgressCallback + ): Promise; + + /** + * Run tests and return formatted output string. + * Uses the reporter specified in options (default: 'console'). + */ + runAndReport( + targetPath: string, + options?: TestHarnessOptions, + onProgress?: TestHarnessProgressCallback + ): Promise<{ output: string; exitCode: number }>; +} + +// --- Registration --- + +let registeredHarness: TestHarness | null = null; + +/** + * Register a test harness implementation. + * Called by the consumer (app, standalone script) to plug in a test framework. + */ +export function registerTestHarness(harness: TestHarness): void { + registeredHarness = harness; +} + +/** + * Get the registered test harness. + * Throws if no harness has been registered. + */ +export function getTestHarness(): TestHarness { + if (!registeredHarness) { + throw new Error( + 'No test harness registered. Install a test framework (e.g., @prompd/test) ' + + 'and call registerTestHarness() before using the test command.' + ); + } + return registeredHarness; +} diff --git a/typescript/src/lib/workflowExecutor.ts b/typescript/src/lib/workflowExecutor.ts index a59a884..e40d1b1 100644 --- a/typescript/src/lib/workflowExecutor.ts +++ b/typescript/src/lib/workflowExecutor.ts @@ -428,7 +428,7 @@ interface ExecutorOptions { onTraceEntry?: (entry: TraceEntry) => void /** Called for streaming output from prompt nodes */ onStream?: (nodeId: string, chunk: string) => void - executePrompt?: (source: string, params: Record, provider?: string, model?: string) => Promise + executePrompt?: (source: string, params: Record, provider?: string, model?: string, temperature?: number, maxTokens?: number) => Promise /** Called by agent nodes to execute LLM prompts with conversation history */ onPromptExecute?: (request: PromptExecuteRequest) => Promise /** Called when an agent node emits a checkpoint event (tool calls, iterations, etc.) */ @@ -1828,7 +1828,9 @@ async function executePromptNode( sourceToExecute, allParams, resolvedProvider, - resolvedModel + resolvedModel, + data.temperature, + data.maxTokens ) } catch (error) { // Emit error checkpoint event diff --git a/typescript/src/lib/workflowTypes.ts b/typescript/src/lib/workflowTypes.ts index 962804f..23a31ba 100644 --- a/typescript/src/lib/workflowTypes.ts +++ b/typescript/src/lib/workflowTypes.ts @@ -283,6 +283,10 @@ export interface PromptNodeData extends BaseNodeData { outputMapping?: Record inputSchema?: JsonSchema outputSchema?: JsonSchema + /** Temperature override (0-2) — overrides frontmatter hint, overridden by provider node */ + temperature?: number + /** Max tokens override — overrides frontmatter hint, overridden by provider node */ + maxTokens?: number /** Guardrail configuration (for content filtering/validation) */ guardrail?: { /** Whether the guardrail is enabled */ diff --git a/typescript/src/types/index.ts b/typescript/src/types/index.ts index 1a96e49..fe28853 100644 --- a/typescript/src/types/index.ts +++ b/typescript/src/types/index.ts @@ -249,6 +249,11 @@ export interface PrompdMetadata { response?: string | string[]; output?: string | string[]; requires?: string[]; + // Execution hints — used as defaults, overridden by CLI flags / node properties + provider?: string; + model?: string; + temperature?: number; + max_tokens?: number; // Advanced features for composable architecture using?: string | UsingPackage[] | Record; // Package imports inherits?: string; // Parent template reference @@ -336,4 +341,7 @@ export interface ExecuteOptions { registryUrl?: string; workspaceRoot?: string; fileSystem?: import('../lib/compiler/file-system').IFileSystem; + // Execution parameter overrides — take priority over frontmatter hints + temperature?: number; + maxTokens?: number; } \ No newline at end of file