diff --git a/.cursor/rules/code-generation.mdc b/.cursor/rules/code-generation.mdc new file mode 100644 index 00000000..8e3f886d --- /dev/null +++ b/.cursor/rules/code-generation.mdc @@ -0,0 +1,102 @@ +--- +description: Code Generation Development Rules for CLI +globs: internal/bundler/*.go, cmd/*.go +alwaysApply: true +--- + +# Code Generation Development Rules + +> **⚠️ IMPORTANT**: These rules work in conjunction with the SDK rules. When updating these CLI rules, also update `sdk-js/.cursor/rules/code-generation.mdc` to keep them in sync. + +## Core Principles + +### Never Modify Generated Content in Source Files +- ❌ NEVER edit files in `sdk-js/src/` that contain generated content +- ❌ NEVER hardcode generated content like `copyWriter` prompts in source files +- ✅ ALWAYS generate content into `node_modules/@agentuity/sdk/dist/` or `src/` directories +- ✅ Use dynamic loading patterns for generated content + +### Code Generation Workflow +1. **Modify CLI generation logic** (e.g., `internal/bundler/prompts.go`) +2. **Update SDK to handle generated content dynamically** (e.g., `src/apis/prompt/index.ts`) +3. **Build and test the full pipeline**: CLI generation → SDK loading → Agent usage + +### Optional Field Handling +- ✅ Generated code should NEVER require optional chaining (`?.`) +- ✅ Always generate both `system` and `prompt` fields, even if empty +- ✅ Empty fields should return empty strings, not undefined +- ❌ Never generate partial objects that require optional chaining + +## CLI-Specific Rules + +### Generation Target Locations +```go +// ✅ Correct: Generate into installed SDK +sdkPath := filepath.Join(root, "node_modules", "@agentuity", "sdk", "dist", "generated") + +// ❌ Wrong: Generate into source SDK +sdkPath := filepath.Join(root, "src", "generated") +``` + +### Path Resolution Priority +1. Try `dist/` directory first (production) +2. Fallback to `src/` directory (development) +3. Always check if SDK exists before generating + +### File Generation Pattern +```go +func FindSDKGeneratedDir(ctx BundleContext, projectDir string) (string, error) { + possibleRoots := []string{ + findWorkspaceInstallDir(ctx.Logger, projectDir), + projectDir, + } + + for _, root := range possibleRoots { + // Try dist directory first (production) + sdkPath := filepath.Join(root, "node_modules", "@agentuity", "sdk", "dist", "generated") + if _, err := os.Stat(filepath.Join(root, "node_modules", "@agentuity", "sdk")); err == nil { + if err := os.MkdirAll(sdkPath, 0755); err == nil { + return sdkPath, nil + } + } + // Fallback to src directory (development) + sdkPath = filepath.Join(root, "node_modules", "@agentuity", "sdk", "src", "generated") + if _, err := os.Stat(filepath.Join(root, "node_modules", "@agentuity", "sdk", "src")); err == nil { + if err := os.MkdirAll(sdkPath, 0755); err == nil { + return sdkPath, nil + } + } + } + return "", fmt.Errorf("could not find @agentuity/sdk in node_modules") +} +``` + +## Common Pitfalls to Avoid + +### ❌ Don't Do This +```go +// Hardcoding generated content in source files +const prompts = `export const prompts = { copyWriter: { ... } };` + +// Generating to source SDK files +sdkPath := filepath.Join(root, "src", "generated") + +// Not checking if SDK exists +os.WriteFile(path, content, 0644) // Without checking if path exists +``` + +### ✅ Do This Instead +```go +// Generate dynamic content from YAML/data +content := GenerateTypeScriptTypes(prompts) + +// Generate to installed SDK +sdkPath := filepath.Join(root, "node_modules", "@agentuity", "sdk", "dist", "generated") + +// Check and create directories +if err := os.MkdirAll(sdkPath, 0755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) +} +``` + +Remember: The CLI's job is to generate content into the installed SDK, not modify source files. \ No newline at end of file diff --git a/.cursor/rules/prompt-docstrings.mdc b/.cursor/rules/prompt-docstrings.mdc new file mode 100644 index 00000000..df6191cd --- /dev/null +++ b/.cursor/rules/prompt-docstrings.mdc @@ -0,0 +1,95 @@ +# Prompt JSDoc Docstrings + +## Goal +Add JSDoc-style docstrings to generated prompt objects that provide better IDE support and make the generated code self-documenting. + +## Requirements + +### 1. JSDoc Format for PromptsCollection +Generate JSDoc comments on the `PromptsCollection` type properties with the following structure: +```typescript +export type PromptsCollection = { + /** + * [Prompt Name] - [Prompt Description] + * + * @prompt + * [Original prompt template with variables] + */ + promptName: PromptName; +}; +``` + +### 2. Content Inclusion +- **Name and Description**: Include both from YAML in format "Name - Description" +- **@prompt**: Include only the original prompt template (not system template) +- **Template Preservation**: Show original templates exactly as written in YAML + +### 3. Template Preservation +- Show original templates exactly as written in YAML +- Preserve variable syntax: `{variable:default}`, `{!variable}`, `{{variable}}` +- Maintain line breaks and formatting +- Escape JSDoc comment characters (`*/` → `* /`) + +### 4. IDE Integration +- Docstrings should be visible in IDE hover tooltips when accessing `prompts.promptName` +- Should work with "Go to Definition" functionality +- Provide IntelliSense documentation +- Show original prompt template for reference + +## Implementation + +### Code Generator Updates +1. **PromptsCollection JSDoc**: Add JSDoc comments to each property in the `PromptsCollection` type +2. **Template Escaping**: Handle JSDoc comment characters in templates +3. **Line Break Handling**: Split templates by newlines and add proper JSDoc formatting +4. **Prompt-Only Focus**: Only include `@prompt` section, not `@system` + +### File Structure +- **`_index.js`**: Contains actual prompt objects (no JSDoc on individual objects) +- **`index.d.ts`**: Contains TypeScript types with JSDoc comments on `PromptsCollection` properties + +## Example Output + +```typescript +export type PromptsCollection = { + /** + * Optional Variables with Defaults - Test optional variables that have default values + * + * @prompt + * Help the user with: {task:their question} + * Use a {tone:friendly} approach. + */ + optionalWithDefaults: OptionalWithDefaults; + /** + * Required Variables Test - Test required variables that must be provided + * + * @prompt + * Complete this {!task} for the user. + * The task must be specified. + */ + requiredVariables: RequiredVariables; +}; +``` + +## Key Decisions + +### Why PromptsCollection Only +- Individual prompt objects are not directly accessible to users +- IDE hover works on `prompts.promptName` which maps to `PromptsCollection` properties +- Avoids redundant JSDoc comments that don't provide value + +### Why Prompt Template Only +- Users primarily care about what the prompt does, not the system instructions +- Keeps JSDoc comments focused and concise +- System templates are implementation details + +### Why No Individual Type JSDoc +- Individual prompt types are not directly used by developers +- JSDoc on `PromptsCollection` properties provides the IDE support needed +- Keeps generated code clean and focused + +## Benefits +- **IDE Support**: Better IntelliSense and hover information on `prompts.promptName` +- **Focused Documentation**: Shows only the prompt template that users will see +- **Clean Code**: No redundant JSDoc comments on unused objects +- **Self-Documenting**: Developers can understand prompts without looking at YAML \ No newline at end of file diff --git a/.cursor/rules/testing.mdc b/.cursor/rules/testing.mdc new file mode 100644 index 00000000..d35f5dd3 --- /dev/null +++ b/.cursor/rules/testing.mdc @@ -0,0 +1,30 @@ +# Testing Rules + +## Use `go run .` for Testing +- ✅ ALWAYS use `go run .` instead of building the binary for testing +- ✅ This is faster and avoids unnecessary binary generation +- ❌ Don't use `go build -o agentuity` unless you need a persistent binary + +## Testing Workflow +```bash +# ✅ Good: Test with go run +go run . bundle +go run . dev +go run . project create + +# ❌ Avoid: Building every time for testing +go build -o agentuity && ./agentuity bundle +``` + +## When to Build +- Only build when you need a persistent binary for: + - Installation scripts + - Distribution + - CI/CD pipelines + - Manual testing outside the project directory + +## Development Benefits +- Faster iteration cycle +- No need to manage binary files +- Cleaner development workflow +- Less disk space usage \ No newline at end of file diff --git a/.cursor/rules/unit-testing.mdc b/.cursor/rules/unit-testing.mdc new file mode 100644 index 00000000..7b4bac66 --- /dev/null +++ b/.cursor/rules/unit-testing.mdc @@ -0,0 +1,106 @@ +--- +description: Unit Testing Rules for CLI +globs: *_test.go +alwaysApply: true +--- + +# Unit Testing Rules + +## Use Testify for All Tests +- ✅ ALWAYS use `github.com/stretchr/testify/assert` and `github.com/stretchr/testify/require` for testing +- ✅ Use `assert.Equal()`, `assert.True()`, `assert.False()`, `assert.NoError()`, `assert.Error()` for assertions +- ✅ Use `require.NoError()`, `require.True()`, `require.False()` for fatal assertions that should stop test execution +- ✅ Use table-driven tests with `t.Run()` for multiple test cases + +## Test Structure Pattern +```go +func TestFunctionName(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"test case 1", "input1", "expected1"}, + {"test case 2", "input2", "expected2"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := FunctionName(test.input) + assert.Equal(t, test.expected, result) + }) + } +} +``` + +## Reference Examples +- See `internal/util/strings_test.go` for basic assertion patterns +- See `internal/util/api_test.go` for complex test scenarios with `require` and `assert` +- See `internal/bundler/bundler_test.go` for table-driven test patterns + +## Common Testify Patterns + +### Basic Assertions +```go +import ( + "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBasicAssertions(t *testing.T) { + // Equality + assert.Equal(t, expected, actual) + assert.NotEqual(t, expected, actual) + + // Boolean checks + assert.True(t, condition) + assert.False(t, condition) + + // Error handling + assert.NoError(t, err) + assert.Error(t, err) + + // Fatal assertions (stop test on failure) + require.NoError(t, err) + require.True(t, condition) +} +``` + +### Table-Driven Tests +```go +func TestMultipleCases(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"empty string", "", ""}, + {"simple case", "input", "output"}, + {"special chars", "input@#$", "output___"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := ProcessInput(test.input) + assert.Equal(t, test.expected, result) + }) + } +} +``` + +### Error Testing +```go +func TestErrorHandling(t *testing.T) { + t.Run("should return error for invalid input", func(t *testing.T) { + err := ProcessInvalidInput("invalid") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid input") + }) + + t.Run("should not return error for valid input", func(t *testing.T) { + err := ProcessValidInput("valid") + assert.NoError(t, err) + }) +} +``` \ No newline at end of file diff --git a/cmd/bundle.go b/cmd/bundle.go index 0c067a59..1be489f3 100644 --- a/cmd/bundle.go +++ b/cmd/bundle.go @@ -40,9 +40,7 @@ Examples: projectContext := project.EnsureProject(ctx, cmd) // Check for prompts evals feature flag - if CheckFeatureFlag(cmd, FeaturePromptsEvals, "enable-prompts-evals") { - projectContext.Logger.Info("Prompts evaluations feature is enabled") - } + promptsEvalsFF := CheckFeatureFlag(cmd, FeaturePromptsEvals, "enable-prompts-evals") production, _ := cmd.Flags().GetBool("production") install, _ := cmd.Flags().GetBool("install") @@ -52,14 +50,15 @@ Examples: description, _ := cmd.Flags().GetString("description") if err := bundler.Bundle(bundler.BundleContext{ - Context: ctx, - Logger: projectContext.Logger, - Project: projectContext.Project, - ProjectDir: projectContext.Dir, - Production: production, - Install: install, - CI: ci, - Writer: os.Stderr, + Context: ctx, + Logger: projectContext.Logger, + Project: projectContext.Project, + ProjectDir: projectContext.Dir, + Production: production, + PromptsEvalsFF: promptsEvalsFF, + Install: install, + CI: ci, + Writer: os.Stderr, }); err != nil { errsystem.New(errsystem.ErrInvalidConfiguration, err, errsystem.WithContextMessage("Failed to bundle project")).ShowErrorAndExit() } diff --git a/cmd/dev.go b/cmd/dev.go index 12fa3e36..17c19cfa 100644 --- a/cmd/dev.go +++ b/cmd/dev.go @@ -48,6 +48,8 @@ Examples: apiUrl, appUrl, transportUrl := util.GetURLs(log) noBuild, _ := cmd.Flags().GetBool("no-build") + promptsEvalsFF := CheckFeatureFlag(cmd, FeaturePromptsEvals, "enable-prompts-evals") + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGINT, syscall.SIGTERM) defer cancel() @@ -156,14 +158,16 @@ Examples: } started := time.Now() var ok bool + tui.ShowSpinner("Building project ...", func() { if err := bundler.Bundle(bundler.BundleContext{ - Context: ctx, - Logger: log, - ProjectDir: dir, - Production: false, - DevMode: true, - Writer: os.Stdout, + Context: ctx, + Logger: log, + ProjectDir: dir, + Production: false, + DevMode: true, + Writer: os.Stdout, + PromptsEvalsFF: promptsEvalsFF, }); err != nil { if err == bundler.ErrBuildFailed { return diff --git a/go.mod b/go.mod index bdf7a1d7..9d9aa53d 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/evanw/esbuild v0.25.0 github.com/fsnotify/fsnotify v1.7.0 github.com/google/uuid v1.6.0 + github.com/iancoleman/strcase v0.3.0 github.com/marcozac/go-jsonc v0.1.1 github.com/mattn/go-isatty v0.0.20 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c diff --git a/go.sum b/go.sum index 2f0f7f6d..0412ddbf 100644 --- a/go.sum +++ b/go.sum @@ -129,6 +129,8 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnV github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= +github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= diff --git a/internal/bundler/bundler.go b/internal/bundler/bundler.go index caa78977..0ed15f77 100644 --- a/internal/bundler/bundler.go +++ b/internal/bundler/bundler.go @@ -2,10 +2,8 @@ package bundler import ( "archive/zip" - "context" "encoding/json" "fmt" - "io" "math" "os" "os/exec" @@ -14,6 +12,7 @@ import ( "strings" "time" + "github.com/agentuity/cli/internal/bundler/prompts" "github.com/agentuity/cli/internal/errsystem" "github.com/agentuity/cli/internal/project" "github.com/agentuity/cli/internal/util" @@ -37,18 +36,6 @@ type AgentConfig struct { Filename string `json:"filename"` } -type BundleContext struct { - Context context.Context - Logger logger.Logger - Project *project.Project - ProjectDir string - Production bool - Install bool - CI bool - DevMode bool - Writer io.Writer -} - func dirSize(path string) (int64, error) { var size int64 err := filepath.Walk(path, func(_ string, info os.FileInfo, err error) error { @@ -421,6 +408,14 @@ func getJSInstallCommand(ctx BundleContext, projectDir, runtime string, isWorksp func bundleJavascript(ctx BundleContext, dir string, outdir string, theproject *project.Project) error { + // Generate prompts if prompts.yaml exists (before dependency installation) + + if ctx.PromptsEvalsFF { + if err := prompts.ProcessPrompts(ctx.Logger, dir); err != nil { + return fmt.Errorf("failed to process prompts: %w", err) + } + } + // Determine where to install dependencies (workspace root or agent directory) installDir := findWorkspaceInstallDir(ctx.Logger, dir) isWorkspace := installDir != dir // We're using workspace root if installDir differs from agent dir diff --git a/internal/bundler/importers.go b/internal/bundler/importers.go index 776cffb7..e6d9a32a 100644 --- a/internal/bundler/importers.go +++ b/internal/bundler/importers.go @@ -253,7 +253,7 @@ func possiblyCreateDeclarationFile(logger logger.Logger, dir string) error { lines := strings.Split(contentStr, "\n") var newLines []string inserted := false - + for _, line := range lines { newLines = append(newLines, line) // Insert after the first export with relative import @@ -285,7 +285,7 @@ func possiblyCreateDeclarationFile(logger logger.Logger, dir string) error { newLines = append(newLines, "import './file_types';") contentStr = strings.Join(newLines, "\n") } - + err = os.WriteFile(sdkIndexPath, []byte(contentStr), 0644) if err != nil { logger.Debug("failed to patch SDK index.d.ts: %v", err) diff --git a/internal/bundler/importers_test.go b/internal/bundler/importers_test.go index ab0ea9b8..a2a49c78 100644 --- a/internal/bundler/importers_test.go +++ b/internal/bundler/importers_test.go @@ -13,19 +13,19 @@ import ( // Mock logger for testing type mockLogger struct{} -func (m *mockLogger) Trace(format string, args ...interface{}) {} -func (m *mockLogger) Debug(format string, args ...interface{}) {} -func (m *mockLogger) Info(format string, args ...interface{}) {} -func (m *mockLogger) Warn(format string, args ...interface{}) {} -func (m *mockLogger) Error(format string, args ...interface{}) {} -func (m *mockLogger) Fatal(format string, args ...interface{}) {} -func (m *mockLogger) IsTraceEnabled() bool { return false } -func (m *mockLogger) IsDebugEnabled() bool { return false } -func (m *mockLogger) IsInfoEnabled() bool { return false } -func (m *mockLogger) IsWarnEnabled() bool { return false } -func (m *mockLogger) IsErrorEnabled() bool { return false } -func (m *mockLogger) IsFatalEnabled() bool { return false } -func (m *mockLogger) WithField(key string, value interface{}) logger.Logger { return m } +func (m *mockLogger) Trace(format string, args ...interface{}) {} +func (m *mockLogger) Debug(format string, args ...interface{}) {} +func (m *mockLogger) Info(format string, args ...interface{}) {} +func (m *mockLogger) Warn(format string, args ...interface{}) {} +func (m *mockLogger) Error(format string, args ...interface{}) {} +func (m *mockLogger) Fatal(format string, args ...interface{}) {} +func (m *mockLogger) IsTraceEnabled() bool { return false } +func (m *mockLogger) IsDebugEnabled() bool { return false } +func (m *mockLogger) IsInfoEnabled() bool { return false } +func (m *mockLogger) IsWarnEnabled() bool { return false } +func (m *mockLogger) IsErrorEnabled() bool { return false } +func (m *mockLogger) IsFatalEnabled() bool { return false } +func (m *mockLogger) WithField(key string, value interface{}) logger.Logger { return m } func (m *mockLogger) WithFields(fields map[string]interface{}) logger.Logger { return m } func (m *mockLogger) WithError(err error) logger.Logger { return m } func (m *mockLogger) Stack(logger logger.Logger) logger.Logger { return m } @@ -117,18 +117,16 @@ import './file_types';`, t.Fatalf("failed to create SDK dir: %v", err) } - // Write test content to index.d.ts + // Write test content to index.d.ts indexPath := filepath.Join(sdkDir, "index.d.ts") err = os.WriteFile(indexPath, []byte(tt.inputContent), 0644) if err != nil { t.Fatalf("failed to write test file: %v", err) } - - // Also need to create the file_types.d.ts file that the patching logic expects to exist // This triggers the SDK patching logic - fileTypesPath := filepath.Join(sdkDir, "file_types.d.ts") + fileTypesPath := filepath.Join(sdkDir, "file_types.d.ts") err = os.WriteFile(fileTypesPath, []byte("// placeholder"), 0644) if err != nil { t.Fatalf("failed to write file_types.d.ts: %v", err) @@ -148,8 +146,6 @@ import './file_types';`, if err != nil { t.Fatalf("failed to read result file: %v", err) } - - resultStr := string(result) if resultStr != tt.expectedOutput { @@ -170,40 +166,40 @@ import './file_types';`, func TestNeedsDeclarationUpdate(t *testing.T) { tests := []struct { - name string - fileContent string - expectedHash string - shouldUpdate bool + name string + fileContent string + expectedHash string + shouldUpdate bool }{ { - name: "file doesn't exist", - fileContent: "", - expectedHash: "abc123", - shouldUpdate: true, + name: "file doesn't exist", + fileContent: "", + expectedHash: "abc123", + shouldUpdate: true, }, { - name: "file has matching hash", - fileContent: "// agentuity-types-hash:abc123\ndeclare module '*.yml' {}", - expectedHash: "abc123", - shouldUpdate: false, + name: "file has matching hash", + fileContent: "// agentuity-types-hash:abc123\ndeclare module '*.yml' {}", + expectedHash: "abc123", + shouldUpdate: false, }, { - name: "file has different hash", - fileContent: "// agentuity-types-hash:def456\ndeclare module '*.yml' {}", - expectedHash: "abc123", - shouldUpdate: true, + name: "file has different hash", + fileContent: "// agentuity-types-hash:def456\ndeclare module '*.yml' {}", + expectedHash: "abc123", + shouldUpdate: true, }, { - name: "file has no hash", - fileContent: "declare module '*.yml' {}", - expectedHash: "abc123", - shouldUpdate: true, + name: "file has no hash", + fileContent: "declare module '*.yml' {}", + expectedHash: "abc123", + shouldUpdate: true, }, { - name: "empty file", - fileContent: "", - expectedHash: "abc123", - shouldUpdate: true, + name: "empty file", + fileContent: "", + expectedHash: "abc123", + shouldUpdate: true, }, } diff --git a/internal/bundler/prompts/code_generator.go b/internal/bundler/prompts/code_generator.go new file mode 100644 index 00000000..f54a630c --- /dev/null +++ b/internal/bundler/prompts/code_generator.go @@ -0,0 +1,528 @@ +package prompts + +import ( + "fmt" + "strings" + + "github.com/iancoleman/strcase" +) + +// CodeGenerator handles generating JavaScript and TypeScript code from prompts +type CodeGenerator struct { + prompts []Prompt +} + +// NewCodeGenerator creates a new code generator +func NewCodeGenerator(prompts []Prompt) *CodeGenerator { + return &CodeGenerator{ + prompts: prompts, + } +} + +// GenerateJavaScript generates the main JavaScript file +func (cg *CodeGenerator) GenerateJavaScript() string { + var objects []string + + for _, prompt := range cg.prompts { + objects = append(objects, cg.generatePromptObject(prompt)) + } + + return fmt.Sprintf(`// Generated prompts - do not edit manually +import { interpolateTemplate } from '@agentuity/sdk'; + +%s + +/** + * Collection of all available prompts with JSDoc documentation + * Each prompt includes original system and prompt templates for reference + */ +export const prompts = { +%s +};`, strings.Join(objects, "\n\n"), cg.generatePromptExports()) +} + +// GenerateTypeScriptTypes generates the TypeScript definitions file +func (cg *CodeGenerator) GenerateTypeScriptTypes() string { + var promptTypes []string + + for _, prompt := range cg.prompts { + promptType := cg.generatePromptType(prompt) + promptTypes = append(promptTypes, promptType) + } + + return fmt.Sprintf(`// Generated prompt types - do not edit manually +import { interpolateTemplate } from '@agentuity/sdk'; + +%s + +export type PromptsCollection = { +%s +}; + +export const prompts: PromptsCollection = {} as any;`, strings.Join(promptTypes, "\n\n"), cg.generatePromptTypeExports()) +} + +// GenerateTypeScriptInterfaces generates the TypeScript interfaces file +func (cg *CodeGenerator) GenerateTypeScriptInterfaces() string { + var interfaces []string + + for _, prompt := range cg.prompts { + interfaceDef := cg.generatePromptInterface(prompt) + interfaces = append(interfaces, interfaceDef) + } + + return fmt.Sprintf(`// Generated prompt interfaces - do not edit manually +%s`, strings.Join(interfaces, "\n\n")) +} + +// generatePromptObject generates a single prompt object with system and prompt properties +func (cg *CodeGenerator) generatePromptObject(prompt Prompt) string { + // Get variables from system template + systemVariables := cg.getSystemVariables(prompt) + var systemParams []string + if len(systemVariables) > 0 { + systemParams = append(systemParams, "variables") + } + systemParamStr := strings.Join(systemParams, ", ") + + // Get variables from prompt template + promptVariables := cg.getPromptVariables(prompt) + var promptParams []string + if len(promptVariables) > 0 { + promptParams = append(promptParams, "variables") + } + promptParamStr := strings.Join(promptParams, ", ") + + return fmt.Sprintf(`const %s = { + slug: %q, + system: { + compile: (%s) => { + return %s + } + }, + prompt: { + compile: (%s) => { + return %s + } + } +};`, strcase.ToLowerCamel(prompt.Slug), prompt.Slug, systemParamStr, cg.generateTemplateValue(prompt.System), promptParamStr, cg.generateTemplateValue(prompt.Prompt)) +} + +// generateTemplateValue generates the value for a template (either compile function or direct interpolateTemplate call) +func (cg *CodeGenerator) generateTemplateValue(template string) string { + if template == "" { + return `""` + } + + return fmt.Sprintf("interpolateTemplate(%q, variables)", template) +} + +// generatePromptType generates a TypeScript type for a prompt object +func (cg *CodeGenerator) generatePromptType(prompt Prompt) string { + // Get variables from system template + systemVariables := cg.getSystemVariableObjects(prompt) + var systemParams []string + if len(systemVariables) > 0 { + systemParams = append(systemParams, fmt.Sprintf("variables?: { %s }", cg.generateVariableTypesFromObjects(systemVariables))) + } + systemParamStr := strings.Join(systemParams, ", ") + + // Get variables from prompt template + promptVariables := cg.getPromptVariableObjects(prompt) + var promptParams []string + if len(promptVariables) > 0 { + promptParams = append(promptParams, fmt.Sprintf("variables?: { %s }", cg.generateVariableTypesFromObjects(promptVariables))) + } + promptParamStr := strings.Join(promptParams, ", ") + + // Generate separate system and prompt types with docstrings + systemTypeName := fmt.Sprintf("%sSystem", strcase.ToCamel(prompt.Slug)) + promptTypeName := fmt.Sprintf("%sPrompt", strcase.ToCamel(prompt.Slug)) + mainTypeName := strcase.ToCamel(prompt.Slug) + + systemTypeWithDocstring := cg.generateTypeWithDocstring(prompt.System, systemTypeName, systemParamStr, mainTypeName) + promptTypeWithDocstring := cg.generateTypeWithDocstring(prompt.Prompt, promptTypeName, promptParamStr, mainTypeName) + + return fmt.Sprintf(`%s + +%s + +export type %s = { + slug: string; + /** +%s + */ + system: %s; + /** +%s + */ + prompt: %s; +};`, + systemTypeWithDocstring, promptTypeWithDocstring, mainTypeName, cg.generateTemplateDocstring(prompt.System), systemTypeName, cg.generateTemplateDocstring(prompt.Prompt), promptTypeName) +} + +// generatePromptInterface generates a TypeScript interface for a prompt +func (cg *CodeGenerator) generatePromptInterface(prompt Prompt) string { + // Get variables from system template + systemVariables := cg.getSystemVariableObjects(prompt) + var systemParams []string + if len(systemVariables) > 0 { + systemParams = append(systemParams, fmt.Sprintf("variables?: { %s }", cg.generateVariableTypesFromObjects(systemVariables))) + } + systemParamStr := strings.Join(systemParams, ", ") + systemCompileType := fmt.Sprintf("(%s) => string", systemParamStr) + + // Get variables from prompt template + promptVariables := cg.getPromptVariableObjects(prompt) + var promptParams []string + if len(promptVariables) > 0 { + promptParams = append(promptParams, fmt.Sprintf("variables?: { %s }", cg.generateVariableTypesFromObjects(promptVariables))) + } + promptParamStr := strings.Join(promptParams, ", ") + promptCompileType := fmt.Sprintf("(%s) => string", promptParamStr) + + return fmt.Sprintf(`export interface %s { + slug: string; + system: { compile: %s }; + prompt: { compile: %s }; +}`, strcase.ToCamel(prompt.Slug), systemCompileType, promptCompileType) +} + +// generateVariableTypes generates TypeScript types for variables +func (cg *CodeGenerator) generateVariableTypes(variables []string) string { + var types []string + for _, variable := range variables { + types = append(types, fmt.Sprintf("%s: string", variable)) + } + return strings.Join(types, "; ") +} + +// generateVariableTypesFromObjects generates TypeScript types for variables with default values +func (cg *CodeGenerator) generateVariableTypesFromObjects(variables []Variable) string { + var types []string + for _, variable := range variables { + if variable.IsRequired { + // Required variables are always string + types = append(types, fmt.Sprintf("%s: string", variable.Name)) + } else if variable.HasDefault { + // Optional variables with defaults: string | "defaultValue" + types = append(types, fmt.Sprintf("%s?: string | %q", variable.Name, variable.DefaultValue)) + } else { + // Optional variables without defaults: string (but parameter is optional) + types = append(types, fmt.Sprintf("%s?: string", variable.Name)) + } + } + return strings.Join(types, "; ") +} + +// generateDocstring generates a JSDoc-style docstring for a prompt +func (cg *CodeGenerator) generateDocstring(prompt Prompt) string { + var docLines []string + docLines = append(docLines, "/**") + + // Add name and description with separate tags + if prompt.Name != "" { + docLines = append(docLines, fmt.Sprintf(" * @name %s", prompt.Name)) + } else { + // Fallback to slug-based name + docLines = append(docLines, fmt.Sprintf(" * @name %s", strcase.ToCamel(prompt.Slug))) + } + + if prompt.Description != "" { + docLines = append(docLines, fmt.Sprintf(" * @description %s", prompt.Description)) + } + + // Add original templates + if prompt.System != "" { + docLines = append(docLines, " *") + docLines = append(docLines, " * @system") + // Escape the template for JSDoc and add proper line breaks + escapedSystem := strings.ReplaceAll(prompt.System, "*/", "* /") + // Split by newlines and add proper JSDoc formatting + systemLines := strings.Split(escapedSystem, "\n") + for _, line := range systemLines { + docLines = append(docLines, fmt.Sprintf(" * %s", line)) + } + } + + if prompt.Prompt != "" { + docLines = append(docLines, " *") + docLines = append(docLines, " * @prompt") + // Escape the template for JSDoc and add proper line breaks + escapedPrompt := strings.ReplaceAll(prompt.Prompt, "*/", "* /") + // Split by newlines and add proper JSDoc formatting + promptLines := strings.Split(escapedPrompt, "\n") + for _, line := range promptLines { + docLines = append(docLines, fmt.Sprintf(" * %s", line)) + } + } + + docLines = append(docLines, " */") + docLines = append(docLines, "") + + return strings.Join(docLines, "\n") +} + +// generatePromptExports generates the exports object for JavaScript +func (cg *CodeGenerator) generatePromptExports() string { + var exports []string + for _, prompt := range cg.prompts { + // Generate JSDoc comment for each prompt property + jsdocComment := cg.generatePromptPropertyJSDoc(prompt) + exports = append(exports, jsdocComment) + exports = append(exports, fmt.Sprintf(" %s,", strcase.ToLowerCamel(prompt.Slug))) + } + return strings.Join(exports, "\n") +} + +// generatePromptTypeExports generates the exports object for TypeScript types +func (cg *CodeGenerator) generatePromptTypeExports() string { + var exports []string + for _, prompt := range cg.prompts { + // Generate JSDoc comment for each prompt property + jsdocComment := cg.generatePromptPropertyJSDoc(prompt) + exports = append(exports, jsdocComment) + exports = append(exports, fmt.Sprintf(" %s: %s;", strcase.ToLowerCamel(prompt.Slug), strcase.ToCamel(prompt.Slug))) + } + return strings.Join(exports, "\n") +} + +// generatePromptPropertyJSDoc generates JSDoc comments for prompt properties in PromptsCollection +func (cg *CodeGenerator) generatePromptPropertyJSDoc(prompt Prompt) string { + var docLines []string + + // Create JSDoc comment with name, description, and templates + docLines = append(docLines, " /**") + + // Add name and description with separate tags + if prompt.Name != "" { + docLines = append(docLines, fmt.Sprintf(" * @name %s", prompt.Name)) + } else { + // Fallback to slug-based name + docLines = append(docLines, fmt.Sprintf(" * @name %s", strcase.ToCamel(prompt.Slug))) + } + + if prompt.Description != "" { + docLines = append(docLines, fmt.Sprintf(" * @description %s", prompt.Description)) + } + + // Add original templates + if prompt.System != "" { + docLines = append(docLines, " *") + docLines = append(docLines, " * @system") + // Escape the template for JSDoc and add proper line breaks + escapedSystem := strings.ReplaceAll(prompt.System, "*/", "* /") + // Split by newlines and add proper JSDoc formatting + systemLines := strings.Split(escapedSystem, "\n") + for _, line := range systemLines { + docLines = append(docLines, fmt.Sprintf(" * %s", line)) + } + } + + if prompt.Prompt != "" { + docLines = append(docLines, " *") + docLines = append(docLines, " * @prompt") + // Escape the template for JSDoc and add proper line breaks + escapedPrompt := strings.ReplaceAll(prompt.Prompt, "*/", "* /") + // Split by newlines and add proper JSDoc formatting + promptLines := strings.Split(escapedPrompt, "\n") + for _, line := range promptLines { + docLines = append(docLines, fmt.Sprintf(" * %s", line)) + } + } + + docLines = append(docLines, " */") + + return strings.Join(docLines, "\n") +} + +// generatePromptTypeJSDoc generates JSDoc comments for individual prompt types +func (cg *CodeGenerator) generatePromptTypeJSDoc(prompt Prompt) string { + var docLines []string + + // Create JSDoc comment with name, description, and prompt template only + docLines = append(docLines, "/**") + + // Add name and description with separate tags + if prompt.Name != "" { + docLines = append(docLines, fmt.Sprintf(" * @name %s", prompt.Name)) + } else { + // Fallback to slug-based name + docLines = append(docLines, fmt.Sprintf(" * @name %s", strcase.ToCamel(prompt.Slug))) + } + + if prompt.Description != "" { + docLines = append(docLines, fmt.Sprintf(" * @description %s", prompt.Description)) + } + + // Add only the prompt template + if prompt.Prompt != "" { + docLines = append(docLines, " *") + docLines = append(docLines, " * @prompt") + // Escape the template for JSDoc and add proper line breaks + escapedPrompt := strings.ReplaceAll(prompt.Prompt, "*/", "* /") + // Split by newlines and add proper JSDoc formatting + promptLines := strings.Split(escapedPrompt, "\n") + for _, line := range promptLines { + docLines = append(docLines, fmt.Sprintf(" * %s", line)) + } + } + + docLines = append(docLines, " */") + docLines = append(docLines, "") + + return strings.Join(docLines, "\n") +} + +// getSystemVariables gets variables from the system template only +func (cg *CodeGenerator) getSystemVariables(prompt Prompt) []string { + // Parse system template if not already parsed + systemTemplate := prompt.SystemTemplate + if len(systemTemplate.Variables) == 0 && prompt.System != "" { + systemTemplate = ParseTemplate(prompt.System) + } + + return systemTemplate.VariableNames() +} + +// getPromptVariables gets variables from the prompt template only +func (cg *CodeGenerator) getPromptVariables(prompt Prompt) []string { + // Parse prompt template if not already parsed + promptTemplate := prompt.PromptTemplate + if len(promptTemplate.Variables) == 0 && prompt.Prompt != "" { + promptTemplate = ParseTemplate(prompt.Prompt) + } + + return promptTemplate.VariableNames() +} + +// getSystemVariableObjects gets variable objects from the system template only +func (cg *CodeGenerator) getSystemVariableObjects(prompt Prompt) []Variable { + // Parse system template if not already parsed + systemTemplate := prompt.SystemTemplate + if len(systemTemplate.Variables) == 0 && prompt.System != "" { + systemTemplate = ParseTemplate(prompt.System) + } + + return systemTemplate.Variables +} + +// getPromptVariableObjects gets variable objects from the prompt template only +func (cg *CodeGenerator) getPromptVariableObjects(prompt Prompt) []Variable { + // Parse prompt template if not already parsed + promptTemplate := prompt.PromptTemplate + if len(promptTemplate.Variables) == 0 && prompt.Prompt != "" { + promptTemplate = ParseTemplate(prompt.Prompt) + } + + return promptTemplate.Variables +} + +// generateTypeWithDocstring generates a separate type with docstring +func (cg *CodeGenerator) generateTypeWithDocstring(template, typeName, paramStr, mainTypeName string) string { + if template == "" { + return fmt.Sprintf(`export type %s = { compile: (%s) => string };`, + typeName, paramStr) + } + + // Generate JSDoc comment for the type with @memberof + docstring := cg.generateTemplateDocstring(template) + + return fmt.Sprintf(`/** +%s + * @memberof %s + * @type {object} + */ +export type %s = { compile: (%s) => string };`, + docstring, mainTypeName, typeName, paramStr) +} + +// generateTemplateDocstring generates the docstring content for any template +func (cg *CodeGenerator) generateTemplateDocstring(template string) string { + if template == "" { + return "" + } + + // Escape the template for docstring and add proper line breaks + escapedTemplate := strings.ReplaceAll(template, "*/", "* /") + // Split by newlines and add proper docstring formatting + templateLines := strings.Split(escapedTemplate, "\n") + var docLines []string + for _, line := range templateLines { + // Add line breaks at natural break points (sentences, periods, etc.) + formattedLine := cg.addNaturalLineBreaks(line) + docLines = append(docLines, fmt.Sprintf(" * %s", formattedLine)) + } + + return strings.Join(docLines, "\n") +} + +// addNaturalLineBreaks adds line breaks at natural break points +func (cg *CodeGenerator) addNaturalLineBreaks(line string) string { + // If line is short enough, return as is + if len(line) <= 60 { + return line + } + + // Look for natural break points: periods, commas, or spaces + // Split at periods followed by space + parts := strings.Split(line, ". ") + if len(parts) > 1 { + var result []string + for i, part := range parts { + if i > 0 { + part = part + "." + } + if len(part) > 60 { + // Further split long parts at commas + commaParts := strings.Split(part, ", ") + if len(commaParts) > 1 { + for j, commaPart := range commaParts { + if j > 0 { + commaPart = commaPart + "," + } + result = append(result, commaPart) + } + } else { + result = append(result, part) + } + } else { + result = append(result, part) + } + } + // Use HTML line breaks instead of newlines + return strings.Join(result, ".
* ") + } + + return line +} + +// wrapLine wraps a long line at the specified width +func (cg *CodeGenerator) wrapLine(line string, width int) []string { + if len(line) <= width { + return []string{line} + } + + var wrapped []string + words := strings.Fields(line) + var currentLine strings.Builder + + for _, word := range words { + // If adding this word would exceed the width, start a new line + if currentLine.Len() > 0 && currentLine.Len()+len(word)+1 > width { + wrapped = append(wrapped, currentLine.String()) + currentLine.Reset() + } + + if currentLine.Len() > 0 { + currentLine.WriteString(" ") + } + currentLine.WriteString(word) + } + + if currentLine.Len() > 0 { + wrapped = append(wrapped, currentLine.String()) + } + + return wrapped +} diff --git a/internal/bundler/prompts/code_generator_test.go b/internal/bundler/prompts/code_generator_test.go new file mode 100644 index 00000000..301f9f9a --- /dev/null +++ b/internal/bundler/prompts/code_generator_test.go @@ -0,0 +1,235 @@ +package prompts + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCodeGenerator(t *testing.T) { + prompts := []Prompt{ + { + Name: "Test Prompt 1", + Slug: "test-prompt-1", + Description: "A test prompt", + System: "You are a {role:assistant} specializing in {!domain}.", + Prompt: "Help the user with {task:their question}.", + }, + { + Name: "Test Prompt 2", + Slug: "test-prompt-2", + Description: "Another test prompt", + Prompt: "Complete this {!task} for the user.", + }, + } + + codeGen := NewCodeGenerator(prompts) + + t.Run("GenerateJavaScript", func(t *testing.T) { + js := codeGen.GenerateJavaScript() + + // Check that it contains the import + assert.Contains(t, js, "import { interpolateTemplate } from '@agentuity/sdk';") + + // Check that it contains the prompts object + assert.Contains(t, js, "export const prompts = {") + + // Check that it contains both prompt objects + assert.Contains(t, js, "const testPrompt1 = {") + assert.Contains(t, js, "const testPrompt2 = {") + + // Check that it contains variables parameter (no TypeScript types) + assert.Contains(t, js, "variables") + + // Check that it contains compile functions + assert.Contains(t, js, "system: {") + assert.Contains(t, js, "prompt: {") + assert.Contains(t, js, "compile: (variables) => {") + assert.Contains(t, js, "interpolateTemplate(") + + // Ensure no TypeScript syntax in JavaScript + assert.NotContains(t, js, ": string", "JavaScript should not contain TypeScript type annotations") + assert.NotContains(t, js, "variables?: {", "JavaScript should not contain TypeScript type annotations") + assert.NotContains(t, js, "export type", "JavaScript should not contain TypeScript type definitions") + assert.NotContains(t, js, "interface ", "JavaScript should not contain TypeScript interfaces") + }) + + t.Run("GenerateTypeScriptTypes", func(t *testing.T) { + types := codeGen.GenerateTypeScriptTypes() + + // Check that it contains the import + assert.Contains(t, types, "import { interpolateTemplate } from '@agentuity/sdk';") + + // Check that it contains the prompts object + assert.Contains(t, types, "export const prompts: PromptsCollection = {} as any;") + + // Check that it contains both prompt types + assert.Contains(t, types, "TestPrompt1") + assert.Contains(t, types, "TestPrompt2") + + // Check that it contains variable types with proper optional/default syntax + assert.Contains(t, types, "variables?: {") + assert.Contains(t, types, "role?: string | \"assistant\"") + assert.Contains(t, types, "domain: string") + assert.Contains(t, types, "task?: string | \"their question\"") + }) + + t.Run("GenerateTypeScriptInterfaces", func(t *testing.T) { + interfaces := codeGen.GenerateTypeScriptInterfaces() + + // Check that it contains both interfaces + assert.Contains(t, interfaces, "export interface TestPrompt1 {") + assert.Contains(t, interfaces, "export interface TestPrompt2 {") + + // Check that it contains variable types with proper optional/default syntax + assert.Contains(t, interfaces, "variables?: {") + assert.Contains(t, interfaces, "role?: string | \"assistant\"") + assert.Contains(t, interfaces, "domain: string") + assert.Contains(t, interfaces, "task?: string | \"their question\"") + + // Check that it contains system and prompt compile functions + assert.Contains(t, interfaces, "system: { compile:") + assert.Contains(t, interfaces, "prompt: { compile:") + }) +} + +func TestCodeGenerator_EmptyPrompts(t *testing.T) { + codeGen := NewCodeGenerator([]Prompt{}) + + t.Run("GenerateJavaScript", func(t *testing.T) { + js := codeGen.GenerateJavaScript() + assert.Contains(t, js, "export const prompts = {") + assert.Contains(t, js, "};") + + // Ensure no TypeScript syntax in JavaScript + assert.NotContains(t, js, ": string", "JavaScript should not contain TypeScript type annotations") + assert.NotContains(t, js, "variables?: {", "JavaScript should not contain TypeScript type annotations") + assert.NotContains(t, js, "export type", "JavaScript should not contain TypeScript type definitions") + assert.NotContains(t, js, "interface ", "JavaScript should not contain TypeScript interfaces") + }) + + t.Run("GenerateTypeScriptTypes", func(t *testing.T) { + types := codeGen.GenerateTypeScriptTypes() + assert.Contains(t, types, "export const prompts: PromptsCollection = {} as any;") + }) + + t.Run("GenerateTypeScriptInterfaces", func(t *testing.T) { + interfaces := codeGen.GenerateTypeScriptInterfaces() + assert.Equal(t, "// Generated prompt interfaces - do not edit manually\n", interfaces) + }) +} + +func TestCodeGenerator_SingleFieldPrompts(t *testing.T) { + prompts := []Prompt{ + { + Name: "System Only", + Slug: "system-only", + System: "You are a {role:assistant}.", + }, + { + Name: "Prompt Only", + Slug: "prompt-only", + Prompt: "Help with {task:the task}.", + }, + } + + codeGen := NewCodeGenerator(prompts) + + t.Run("GenerateJavaScript", func(t *testing.T) { + js := codeGen.GenerateJavaScript() + + // Check that it contains the correct compile functions + assert.Contains(t, js, "system: {") + assert.Contains(t, js, "prompt: {") + assert.Contains(t, js, "compile: (variables) => {") + assert.Contains(t, js, "slug:") + + // Ensure no TypeScript syntax in JavaScript + assert.NotContains(t, js, ": string", "JavaScript should not contain TypeScript type annotations") + assert.NotContains(t, js, "variables?: {", "JavaScript should not contain TypeScript type annotations") + assert.NotContains(t, js, "export type", "JavaScript should not contain TypeScript type definitions") + assert.NotContains(t, js, "interface ", "JavaScript should not contain TypeScript interfaces") + }) + + t.Run("GenerateTypeScriptTypes", func(t *testing.T) { + types := codeGen.GenerateTypeScriptTypes() + + // Check that both prompts have string return types + assert.Contains(t, types, "=> string") + }) +} + +func TestCodeGenerator_ComplexPrompts(t *testing.T) { + prompts := []Prompt{ + { + Name: "Complex Prompt", + Slug: "complex-prompt", + Description: "A complex prompt with both system and prompt", + System: "You are a {role:helpful assistant} specializing in {!domain}.\nYour experience level is {experience:intermediate}.", + Prompt: "Help the user with: {task:their question}\nUse a {approach:detailed} approach.\nPriority: {priority:normal}", + }, + } + + codeGen := NewCodeGenerator(prompts) + + t.Run("GenerateJavaScript", func(t *testing.T) { + js := codeGen.GenerateJavaScript() + + // Check that it handles multiline templates correctly + assert.Contains(t, js, "interpolateTemplate(\"You are a {role:helpful assistant} specializing in {!domain}.\\nYour experience level is {experience:intermediate}.\", variables)") + assert.Contains(t, js, "interpolateTemplate(\"Help the user with: {task:their question}\\nUse a {approach:detailed} approach.\\nPriority: {priority:normal}\", variables)") + + // Check that it contains the correct object structure + assert.Contains(t, js, "const complexPrompt = {") + assert.Contains(t, js, "system: {") + assert.Contains(t, js, "prompt: {") + assert.Contains(t, js, "compile: (variables) => {") + assert.Contains(t, js, "slug:") + + // Ensure no TypeScript syntax in JavaScript + assert.NotContains(t, js, ": string", "JavaScript should not contain TypeScript type annotations") + assert.NotContains(t, js, "variables?: {", "JavaScript should not contain TypeScript type annotations") + assert.NotContains(t, js, "export type", "JavaScript should not contain TypeScript type definitions") + assert.NotContains(t, js, "interface ", "JavaScript should not contain TypeScript interfaces") + }) + + t.Run("GenerateTypeScriptTypes", func(t *testing.T) { + types := codeGen.GenerateTypeScriptTypes() + + // Check that it has the correct object structure for complex prompts + assert.Contains(t, types, "system: ComplexPromptSystem;") + assert.Contains(t, types, "prompt: ComplexPromptPrompt;") + assert.Contains(t, types, "slug: string;") + + // Check that it includes all variables with proper optional/default syntax + assert.Contains(t, types, "variables?: {") + assert.Contains(t, types, "role?: string | \"helpful assistant\"") + assert.Contains(t, types, "domain: string") + assert.Contains(t, types, "experience?: string | \"intermediate\"") + assert.Contains(t, types, "task?: string | \"their question\"") + assert.Contains(t, types, "approach?: string | \"detailed\"") + assert.Contains(t, types, "priority?: string | \"normal\"") + }) +} + +func TestCodeGenerator_VariableTypes(t *testing.T) { + prompts := []Prompt{ + { + Name: "Variable Test", + Slug: "variable-test", + System: "You are a {{legacy}} {new:default} {!required} assistant.", + }, + } + + codeGen := NewCodeGenerator(prompts) + + t.Run("GenerateVariableTypes", func(t *testing.T) { + types := codeGen.GenerateTypeScriptTypes() + + // Check that it includes all variable types with proper optional/default syntax + assert.Contains(t, types, "variables?: {") + assert.Contains(t, types, "legacy?: string") + assert.Contains(t, types, "new?: string | \"default\"") + assert.Contains(t, types, "required: string") + }) +} diff --git a/internal/bundler/prompts/prompt_parser.go b/internal/bundler/prompts/prompt_parser.go new file mode 100644 index 00000000..e6684618 --- /dev/null +++ b/internal/bundler/prompts/prompt_parser.go @@ -0,0 +1,45 @@ +package prompts + +import ( + "fmt" + + "gopkg.in/yaml.v3" +) + +// PromptsYAML represents the structure of prompts.yaml for unmarshaling +type PromptsYAML struct { + Prompts []Prompt `yaml:"prompts"` +} + +// ParsePromptsYAML parses YAML bytes and returns the prompt definitions +func ParsePromptsYAML(data []byte) ([]Prompt, error) { + var promptsData PromptsYAML + if err := yaml.Unmarshal(data, &promptsData); err != nil { + return nil, fmt.Errorf("failed to parse prompts.yaml: %w", err) + } + + if len(promptsData.Prompts) == 0 { + return nil, fmt.Errorf("no prompts found in prompts.yaml") + } + + // Validate and parse templates for each prompt + for i, prompt := range promptsData.Prompts { + if prompt.Name == "" || prompt.Slug == "" { + return nil, fmt.Errorf("prompt at index %d is missing required fields (name, slug)", i) + } + // At least one of system or prompt must be present + if prompt.System == "" && prompt.Prompt == "" { + return nil, fmt.Errorf("prompt at index %d must have at least one of system or prompt", i) + } + + // Parse templates + if prompt.System != "" { + promptsData.Prompts[i].SystemTemplate = ParseTemplate(prompt.System) + } + if prompt.Prompt != "" { + promptsData.Prompts[i].PromptTemplate = ParseTemplate(prompt.Prompt) + } + } + + return promptsData.Prompts, nil +} diff --git a/internal/bundler/prompts/prompts.go b/internal/bundler/prompts/prompts.go new file mode 100644 index 00000000..25dd26dc --- /dev/null +++ b/internal/bundler/prompts/prompts.go @@ -0,0 +1,120 @@ +package prompts + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/agentuity/go-common/logger" +) + +// VariableInfo holds information about extracted variables +type VariableInfo struct { + Names []string +} + +// FindPromptsYAML finds prompts.yaml in the given directory +func FindPromptsYAML(dir string) string { + possiblePaths := []string{ + filepath.Join(dir, "src", "prompts.yaml"), + filepath.Join(dir, "src", "prompts.yml"), + filepath.Join(dir, "prompts.yaml"), + filepath.Join(dir, "prompts.yml"), + } + + for _, path := range possiblePaths { + if _, err := os.Stat(path); err == nil { + return path + } + } + + return "" +} + +// FindSDKGeneratedDir finds the SDK's generated directory in node_modules +func FindSDKGeneratedDir(logger logger.Logger, projectDir string) (string, error) { + // Try project dir first + possibleRoots := []string{ + projectDir, + } + + for _, root := range possibleRoots { + // For production SDK, generate into the new prompt folder structure + sdkPath := filepath.Join(root, "node_modules", "@agentuity", "sdk", "dist", "apis", "prompt", "generated") + if _, err := os.Stat(filepath.Join(root, "node_modules", "@agentuity", "sdk")); err == nil { + // SDK exists, ensure generated directory exists + if err := os.MkdirAll(sdkPath, 0755); err != nil { + logger.Debug("failed to create directory %s: %v", sdkPath, err) + // Try next location + } else { + return sdkPath, nil + } + } + // Fallback to src directory (development) + sdkPath = filepath.Join(root, "node_modules", "@agentuity", "sdk", "src", "apis", "prompt", "generated") + if _, err := os.Stat(filepath.Join(root, "node_modules", "@agentuity", "sdk", "src", "apis", "prompt")); err == nil { + if err := os.MkdirAll(sdkPath, 0755); err != nil { + logger.Debug("failed to create directory %s: %v", sdkPath, err) + } else { + return sdkPath, nil + } + } + } + + return "", fmt.Errorf("could not find @agentuity/sdk in node_modules") +} + +// ProcessPrompts finds, parses, and generates prompt files into the SDK +func ProcessPrompts(logger logger.Logger, projectDir string) error { + // Find prompts.yaml + promptsPath := FindPromptsYAML(projectDir) + if promptsPath == "" { + // No prompts.yaml found - this is OK, not all projects will have prompts + logger.Debug("No prompts.yaml found in project, skipping prompt generation") + return nil + } + + logger.Debug("Found prompts.yaml at: %s", promptsPath) + + // Read and parse prompts.yaml + data, err := os.ReadFile(promptsPath) + if err != nil { + return fmt.Errorf("failed to read prompts.yaml: %w", err) + } + + promptsList, err := ParsePromptsYAML(data) + if err != nil { + return fmt.Errorf("failed to parse prompts: %w", err) + } + + logger.Debug("Parsed %d prompts from YAML", len(promptsList)) + + // Find SDK generated directory + sdkGeneratedDir, err := FindSDKGeneratedDir(logger, projectDir) + if err != nil { + return fmt.Errorf("failed to find SDK directory: %w", err) + } + + logger.Debug("Found SDK generated directory: %s", sdkGeneratedDir) + + // Generate code using the code generator + codeGen := NewCodeGenerator(promptsList) + + // Generate index.js file (overwrite SDK's placeholder, following POC pattern) + jsContent := codeGen.GenerateJavaScript() + jsPath := filepath.Join(sdkGeneratedDir, "_index.js") + if err := os.WriteFile(jsPath, []byte(jsContent), 0644); err != nil { + return fmt.Errorf("failed to write index.js: %w", err) + } + + // Generate index.d.ts file (overwrite SDK's placeholder, following POC pattern) + dtsContent := codeGen.GenerateTypeScriptTypes() + dtsPath := filepath.Join(sdkGeneratedDir, "index.d.ts") + if err := os.WriteFile(dtsPath, []byte(dtsContent), 0644); err != nil { + return fmt.Errorf("failed to write index.d.ts: %w", err) + } + + logger.Info("Generated prompts into SDK: %s and %s", jsPath, dtsPath) + + return nil +} diff --git a/internal/bundler/prompts/template_parser.go b/internal/bundler/prompts/template_parser.go new file mode 100644 index 00000000..657e74f9 --- /dev/null +++ b/internal/bundler/prompts/template_parser.go @@ -0,0 +1,168 @@ +package prompts + +import ( + "regexp" + "strings" +) + +// Variable represents a single variable found in a template +type Variable struct { + Name string `json:"name"` + IsRequired bool `json:"is_required"` + HasDefault bool `json:"has_default"` + DefaultValue string `json:"default_value,omitempty"` + OriginalSyntax string `json:"original_syntax"` +} + +// Template represents a parsed template with its variables +type Template struct { + OriginalTemplate string `json:"original_template"` + Variables []Variable `json:"variables"` +} + +// RequiredVariables returns all required variables +func (t Template) RequiredVariables() []Variable { + var result []Variable + for _, v := range t.Variables { + if v.IsRequired { + result = append(result, v) + } + } + return result +} + +// OptionalVariables returns all optional variables +func (t Template) OptionalVariables() []Variable { + var result []Variable + for _, v := range t.Variables { + if !v.IsRequired { + result = append(result, v) + } + } + return result +} + +// VariablesWithDefaults returns all variables that have default values +func (t Template) VariablesWithDefaults() []Variable { + var result []Variable + for _, v := range t.Variables { + if v.HasDefault { + result = append(result, v) + } + } + return result +} + +// VariablesWithoutDefaults returns all variables that don't have default values +func (t Template) VariablesWithoutDefaults() []Variable { + var result []Variable + for _, v := range t.Variables { + if !v.HasDefault { + result = append(result, v) + } + } + return result +} + +// VariableNames returns just the names of all variables +func (t Template) VariableNames() []string { + names := make([]string, len(t.Variables)) + for i, v := range t.Variables { + names[i] = v.Name + } + return names +} + +// RequiredVariableNames returns just the names of required variables +func (t Template) RequiredVariableNames() []string { + var names []string + for _, v := range t.Variables { + if v.IsRequired { + names = append(names, v.Name) + } + } + return names +} + +// OptionalVariableNames returns just the names of optional variables +func (t Template) OptionalVariableNames() []string { + var names []string + for _, v := range t.Variables { + if !v.IsRequired { + names = append(names, v.Name) + } + } + return names +} + +// Regex for extracting variables from both {{variable}} and {variable:default} syntax (used in YAML) +// This supports both legacy {{variable}} and new {variable:default} syntax +// Also supports {!variable:-default} syntax for required variables with defaults +var variableRegex = regexp.MustCompile(`\{\{([^}]+)\}\}|\{([!]?[^}:]+)(?::(-?[^}]*))?\}`) + +// ParseTemplate parses a template string and returns structured information about all variables +// Supports {{variable}}, {{!required}}, {variable:default}, {!variable:default} syntax +func ParseTemplate(template string) Template { + matches := variableRegex.FindAllStringSubmatch(template, -1) + variables := make([]Variable, 0, len(matches)) + seen := make(map[string]bool) + + for _, match := range matches { + // Ensure we have at least 4 elements: full match + 3 capture groups + if len(match) < 4 { + continue // Skip malformed matches + } + + var varName string + var isRequired bool + var hasDefault bool + var defaultValue string + var originalSyntax string + + // Handle {{variable}} syntax (match[1]) + if match[1] != "" { + varName = strings.TrimSpace(match[1]) + isRequired = false // {{variable}} is always optional + hasDefault = false // {{variable}} has no default + originalSyntax = "{{" + varName + "}}" + } else if match[2] != "" { + // Handle {variable:default} syntax (match[2]) + originalSyntax = match[0] // Full match including braces + varName = strings.TrimSpace(match[2]) + isRequired = strings.HasPrefix(varName, "!") + hasDefault = match[3] != "" // Has default if match[3] is not empty + defaultValue = match[3] + + // Clean up the variable name + if isRequired && len(varName) > 1 { + varName = varName[1:] // Remove ! prefix + } + if hasDefault { + // Remove :default suffix + if idx := strings.Index(varName, ":"); idx != -1 { + varName = varName[:idx] + } + // Handle :- syntax for required variables with defaults + if len(defaultValue) > 0 && strings.HasPrefix(defaultValue, "-") { + defaultValue = defaultValue[1:] // Remove leading dash + } + } + } + + if varName != "" && !seen[varName] { + seen[varName] = true + variables = append(variables, Variable{ + Name: varName, + IsRequired: isRequired, + HasDefault: hasDefault, + DefaultValue: defaultValue, + OriginalSyntax: originalSyntax, + }) + } + } + + return Template{ + OriginalTemplate: template, + Variables: variables, + } +} diff --git a/internal/bundler/prompts/template_parser_test.go b/internal/bundler/prompts/template_parser_test.go new file mode 100644 index 00000000..621de76d --- /dev/null +++ b/internal/bundler/prompts/template_parser_test.go @@ -0,0 +1,194 @@ +package prompts + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseTemplate(t *testing.T) { + t.Run("empty template", func(t *testing.T) { + result := ParseTemplate("") + assert.Equal(t, "", result.OriginalTemplate) + assert.Empty(t, result.Variables) + }) + + t.Run("no variables", func(t *testing.T) { + result := ParseTemplate("You are a helpful assistant.") + assert.Equal(t, "You are a helpful assistant.", result.OriginalTemplate) + assert.Empty(t, result.Variables) + }) + + t.Run("legacy {{variable}} syntax", func(t *testing.T) { + result := ParseTemplate("You are a {{role}} assistant.") + assert.Equal(t, "You are a {{role}} assistant.", result.OriginalTemplate) + require.Len(t, result.Variables, 1) + + v := result.Variables[0] + assert.Equal(t, "role", v.Name) + assert.False(t, v.IsRequired) + assert.False(t, v.HasDefault) + assert.Equal(t, "", v.DefaultValue) + assert.Equal(t, "{{role}}", v.OriginalSyntax) + }) + + t.Run("optional variable with default", func(t *testing.T) { + result := ParseTemplate("You are a {role:helpful assistant} specializing in {domain:general topics}.") + assert.Equal(t, "You are a {role:helpful assistant} specializing in {domain:general topics}.", result.OriginalTemplate) + require.Len(t, result.Variables, 2) + + // Check role variable + roleVar := result.Variables[0] + assert.Equal(t, "role", roleVar.Name) + assert.False(t, roleVar.IsRequired) + assert.True(t, roleVar.HasDefault) + assert.Equal(t, "helpful assistant", roleVar.DefaultValue) + assert.Equal(t, "{role:helpful assistant}", roleVar.OriginalSyntax) + + // Check domain variable + domainVar := result.Variables[1] + assert.Equal(t, "domain", domainVar.Name) + assert.False(t, domainVar.IsRequired) + assert.True(t, domainVar.HasDefault) + assert.Equal(t, "general topics", domainVar.DefaultValue) + assert.Equal(t, "{domain:general topics}", domainVar.OriginalSyntax) + }) + + t.Run("required variable", func(t *testing.T) { + result := ParseTemplate("You are a {!role} assistant.") + assert.Equal(t, "You are a {!role} assistant.", result.OriginalTemplate) + require.Len(t, result.Variables, 1) + + v := result.Variables[0] + assert.Equal(t, "role", v.Name) + assert.True(t, v.IsRequired) + assert.False(t, v.HasDefault) + assert.Equal(t, "", v.DefaultValue) + assert.Equal(t, "{!role}", v.OriginalSyntax) + }) + + t.Run("required variable with default", func(t *testing.T) { + result := ParseTemplate("You are a {!role:-expert} assistant.") + assert.Equal(t, "You are a {!role:-expert} assistant.", result.OriginalTemplate) + require.Len(t, result.Variables, 1) + + v := result.Variables[0] + assert.Equal(t, "role", v.Name) + assert.True(t, v.IsRequired) + assert.True(t, v.HasDefault) + assert.Equal(t, "expert", v.DefaultValue) // Note: should be "expert" not "-expert" + assert.Equal(t, "{!role:-expert}", v.OriginalSyntax) + }) + + t.Run("mixed syntax", func(t *testing.T) { + result := ParseTemplate("You are a {{role}} {domain:AI} {!specialization} assistant.") + assert.Equal(t, "You are a {{role}} {domain:AI} {!specialization} assistant.", result.OriginalTemplate) + require.Len(t, result.Variables, 3) + + // Check role (legacy) + roleVar := result.Variables[0] + assert.Equal(t, "role", roleVar.Name) + assert.False(t, roleVar.IsRequired) + assert.False(t, roleVar.HasDefault) + assert.Equal(t, "{{role}}", roleVar.OriginalSyntax) + + // Check domain (optional with default) + domainVar := result.Variables[1] + assert.Equal(t, "domain", domainVar.Name) + assert.False(t, domainVar.IsRequired) + assert.True(t, domainVar.HasDefault) + assert.Equal(t, "AI", domainVar.DefaultValue) + assert.Equal(t, "{domain:AI}", domainVar.OriginalSyntax) + + // Check specialization (required) + specVar := result.Variables[2] + assert.Equal(t, "specialization", specVar.Name) + assert.True(t, specVar.IsRequired) + assert.False(t, specVar.HasDefault) + assert.Equal(t, "{!specialization}", specVar.OriginalSyntax) + }) + + t.Run("duplicate variables", func(t *testing.T) { + result := ParseTemplate("You are a {role:assistant} {role:helper}.") + assert.Equal(t, "You are a {role:assistant} {role:helper}.", result.OriginalTemplate) + require.Len(t, result.Variables, 1) // Should deduplicate + + v := result.Variables[0] + assert.Equal(t, "role", v.Name) + assert.False(t, v.IsRequired) + assert.True(t, v.HasDefault) + assert.Equal(t, "assistant", v.DefaultValue) // Should use first occurrence + }) +} + +func TestTemplateMethods(t *testing.T) { + template := ParseTemplate("You are a {role:assistant} {!specialization} {domain:AI} helper.") + + t.Run("RequiredVariables", func(t *testing.T) { + required := template.RequiredVariables() + require.Len(t, required, 1) + assert.Equal(t, "specialization", required[0].Name) + }) + + t.Run("OptionalVariables", func(t *testing.T) { + optional := template.OptionalVariables() + require.Len(t, optional, 2) + + names := make(map[string]bool) + for _, v := range optional { + names[v.Name] = true + } + assert.True(t, names["role"]) + assert.True(t, names["domain"]) + }) + + t.Run("VariablesWithDefaults", func(t *testing.T) { + withDefaults := template.VariablesWithDefaults() + require.Len(t, withDefaults, 2) + + names := make(map[string]bool) + for _, v := range withDefaults { + names[v.Name] = true + } + assert.True(t, names["role"]) + assert.True(t, names["domain"]) + }) + + t.Run("VariablesWithoutDefaults", func(t *testing.T) { + withoutDefaults := template.VariablesWithoutDefaults() + require.Len(t, withoutDefaults, 1) + assert.Equal(t, "specialization", withoutDefaults[0].Name) + }) + + t.Run("VariableNames", func(t *testing.T) { + names := template.VariableNames() + require.Len(t, names, 3) + + nameSet := make(map[string]bool) + for _, name := range names { + nameSet[name] = true + } + assert.True(t, nameSet["role"]) + assert.True(t, nameSet["specialization"]) + assert.True(t, nameSet["domain"]) + }) + + t.Run("RequiredVariableNames", func(t *testing.T) { + names := template.RequiredVariableNames() + require.Len(t, names, 1) + assert.Equal(t, "specialization", names[0]) + }) + + t.Run("OptionalVariableNames", func(t *testing.T) { + names := template.OptionalVariableNames() + require.Len(t, names, 2) + + nameSet := make(map[string]bool) + for _, name := range names { + nameSet[name] = true + } + assert.True(t, nameSet["role"]) + assert.True(t, nameSet["domain"]) + }) +} diff --git a/internal/bundler/prompts/types.go b/internal/bundler/prompts/types.go new file mode 100644 index 00000000..74413d5b --- /dev/null +++ b/internal/bundler/prompts/types.go @@ -0,0 +1,15 @@ +package prompts + +// Prompt represents a single prompt definition from YAML +type Prompt struct { + Name string `yaml:"name"` + Slug string `yaml:"slug"` + Description string `yaml:"description"` + System string `yaml:"system"` + Prompt string `yaml:"prompt"` + Evals []string `yaml:"evals,omitempty"` + + // Parsed template information + SystemTemplate Template `json:"system_template,omitempty"` + PromptTemplate Template `json:"prompt_template,omitempty"` +} diff --git a/internal/bundler/types.go b/internal/bundler/types.go new file mode 100644 index 00000000..80d317de --- /dev/null +++ b/internal/bundler/types.go @@ -0,0 +1,23 @@ +package bundler + +import ( + "context" + "io" + + "github.com/agentuity/cli/internal/project" + "github.com/agentuity/go-common/logger" +) + +// BundleContext holds the context for bundling operations +type BundleContext struct { + Context context.Context + Logger logger.Logger + Project *project.Project + ProjectDir string + Production bool + Install bool + CI bool + DevMode bool + Writer io.Writer + PromptsEvalsFF bool +}