From 8c6744eecb37bcb0b1ff1315a5b7dbb7586e59c5 Mon Sep 17 00:00:00 2001 From: Bobby Christopher Date: Mon, 15 Sep 2025 12:38:40 -0400 Subject: [PATCH 1/9] Add prompt id patch + eval pull (#444) * added patch * added evals function * added eval cmd --- cmd/eval.go | 305 ++++++++++++++++++++++++++++++++++ internal/bundler/vercel_ai.go | 8 +- 2 files changed, 312 insertions(+), 1 deletion(-) create mode 100644 cmd/eval.go diff --git a/cmd/eval.go b/cmd/eval.go new file mode 100644 index 00000000..800dd0e7 --- /dev/null +++ b/cmd/eval.go @@ -0,0 +1,305 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "os/signal" + "path/filepath" + "syscall" + + "github.com/agentuity/cli/internal/errsystem" + "github.com/agentuity/cli/internal/project" + "github.com/agentuity/cli/internal/util" + "github.com/agentuity/go-common/env" + "github.com/agentuity/go-common/logger" + "github.com/agentuity/go-common/tui" + "github.com/charmbracelet/huh/spinner" + "github.com/spf13/cobra" +) + +type EvalObject struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + ProjectID string `json:"projectId"` + OrgID string `json:"orgId"` +} + +type EvalPullObject struct { + Code string `json:"code"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` +} + +type EvalCreateResponse = project.Response[EvalObject] +type EvalPullResponse = project.Response[EvalPullObject] + +func CreateGenerativeEvaluation(ctx context.Context, logger logger.Logger, baseUrl string, token string, projectId string) (string, error) { + client := util.NewAPIClient(ctx, logger, baseUrl, token) + + var resp EvalCreateResponse + payload := map[string]any{ + "projectId": projectId, + "type": "generative", + } + + if err := client.Do("POST", "/cli/eval", payload, &resp); err != nil { + return "", fmt.Errorf("error creating generative evaluation: %w", err) + } + if !resp.Success { + return "", fmt.Errorf("failed to create generative evaluation: %s", resp.Message) + } + return resp.Data.ID, nil +} + +func CreateTemplateEvaluation(ctx context.Context, logger logger.Logger, baseUrl string, token string, projectId string, name string, description string) (string, error) { + client := util.NewAPIClient(ctx, logger, baseUrl, token) + + var resp EvalCreateResponse + payload := map[string]any{ + "projectId": projectId, + "name": name, + "description": description, + "type": "template", + } + + if err := client.Do("POST", "/cli/eval", payload, &resp); err != nil { + return "", fmt.Errorf("error creating template evaluation: %w", err) + } + if !resp.Success { + return "", fmt.Errorf("failed to create template evaluation: %s", resp.Message) + } + return resp.Data.ID, nil +} + +func PullEvaluation(ctx context.Context, logger logger.Logger, baseUrl string, token string, evalId string) (*EvalPullObject, error) { + client := util.NewAPIClient(ctx, logger, baseUrl, token) + + var resp EvalPullResponse + if err := client.Do("GET", fmt.Sprintf("/cli/eval/pull/%s", evalId), nil, &resp); err != nil { + return nil, fmt.Errorf("error pulling evaluation: %w", err) + } + if !resp.Success { + return nil, fmt.Errorf("failed to pull evaluation: %s", resp.Message) + } + return &resp.Data, nil +} + +var evalCmd = &cobra.Command{ + Use: "eval", + Short: "Evaluation related commands", + Long: `Evaluation related commands for managing evaluations and test data. + +Use the subcommands to create and pull evaluation data to/from the cloud.`, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +var evalCreateCmd = &cobra.Command{ + Use: "create [name] [description]", + Short: "Create evaluation data in the cloud", + Long: `Create evaluation data in the cloud for your project. + +Arguments: + [name] Optional name for the evaluation + [description] Optional description for the evaluation + +Flags: + --force Don't prompt for confirmation + +Examples: + agentuity eval create + agentuity eval create "My Eval" "Description of evaluation" + agentuity eval create --force "My Eval" "Description"`, + Run: func(cmd *cobra.Command, args []string) { + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + logger := env.NewLogger(cmd) + context := project.EnsureProject(ctx, cmd) + dir := context.Dir + apiUrl := context.APIURL + apiKey := context.Token + theproject := context.Project + + force, _ := cmd.Flags().GetBool("force") + + // First, get the evaluation type + var evalType string + if !tui.HasTTY { + // Default to template when no TTY + evalType = "template" + } else { + evalType = tui.Select(logger, "What type of evaluation would you like to create?", "Choose between template-based or generative evaluation", []tui.Option{ + {Text: tui.PadRight("Template", 20, " ") + tui.Muted("Use a predefined regex evaluation template"), ID: "template"}, + {Text: tui.PadRight("Generative", 20, " ") + tui.Muted("AI will generate custom evaluation code"), ID: "generative"}, + }) + } + + var name, description string + + // Get name and description only for template type + if evalType == "template" { + // Get name and description from args or prompt + if len(args) > 0 { + name = args[0] + } + if len(args) > 1 { + description = args[1] + } + + // Interactive flow for name and description + if name == "" { + if !tui.HasTTY { + logger.Fatal("No TTY detected, please specify an evaluation name from the command line") + } + name = tui.InputWithValidation(logger, "What should we name this evaluation?", "The name helps identify the evaluation", 255, func(name string) error { + if name == "" { + return fmt.Errorf("evaluation name cannot be empty") + } + return nil + }) + } + + if description == "" { + description = tui.Input(logger, "How should we describe what this evaluation tests?", "The description is optional but helpful for understanding the purpose of the evaluation") + } + } + + // Confirm create unless force flag is set + if !force { + var confirmMessage string + if evalType == "template" { + confirmMessage = fmt.Sprintf("Create template evaluation '%s' in the cloud?", name) + } else { + confirmMessage = "Create generative evaluation in the cloud?" + } + + if !tui.Ask(logger, confirmMessage, false) { + tui.ShowWarning("cancelled") + return + } + } + + var evalId string + var evalObj *EvalPullObject + action := func() { + var err error + + // Call the appropriate function based on type + if evalType == "template" { + evalId, err = CreateTemplateEvaluation(ctx, logger, apiUrl, apiKey, theproject.ProjectId, name, description) + } else { + evalId, err = CreateGenerativeEvaluation(ctx, logger, apiUrl, apiKey, theproject.ProjectId) + } + + if err != nil { + errsystem.New(errsystem.ErrApiRequest, err, errsystem.WithUserMessage("Failed to create evaluation")).ShowErrorAndExit() + } + + // Automatically pull the evaluation data + evalObj, err = PullEvaluation(ctx, logger, apiUrl, apiKey, evalId) + if err != nil { + errsystem.New(errsystem.ErrApiRequest, err, errsystem.WithUserMessage("Failed to pull evaluation data")).ShowErrorAndExit() + } + } + + spinner.New().Title("Creating evaluation...").Action(action).Run() + + // Write code to file + filename := evalObj.Name + ".ts" + evalsDir := filepath.Join(dir, "src", "evals") + + // Create the evals directory if it doesn't exist + if err := os.MkdirAll(evalsDir, 0755); err != nil { + errsystem.New(errsystem.ErrCreateDirectory, err, errsystem.WithUserMessage("Failed to create evals directory")).ShowErrorAndExit() + } + + filePath := filepath.Join(evalsDir, filename) + if err := os.WriteFile(filePath, []byte(evalObj.Code), 0644); err != nil { + errsystem.New(errsystem.ErrOpenFile, err, errsystem.WithUserMessage("Failed to write evaluation code to file")).ShowErrorAndExit() + } + + if evalType == "template" { + tui.ShowSuccess("Template evaluation '%s' created successfully with ID: %s", name, evalId) + } else { + tui.ShowSuccess("Generative evaluation created successfully with ID: %s", evalId) + } + + tui.ShowSuccess("Evaluation code written to: %s", filePath) + fmt.Println("\nEvaluation code:") + fmt.Println(evalObj.Code) + }, +} + +var evalPullCmd = &cobra.Command{ + Use: "pull ", + Short: "Pull evaluation data from the cloud by ID", + Long: `Pull evaluation data from the cloud for your project using the evaluation ID. + +Arguments: + The evaluation ID to pull + +Examples: + agentuity eval pull abc123 + agentuity eval pull def456`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + logger := env.NewLogger(cmd) + context := project.EnsureProject(ctx, cmd) + dir := context.Dir + apiUrl := context.APIURL + apiKey := context.Token + + evalId := args[0] + + var evalObj *EvalPullObject + action := func() { + var err error + evalObj, err = PullEvaluation(ctx, logger, apiUrl, apiKey, evalId) + if err != nil { + errsystem.New(errsystem.ErrApiRequest, err, errsystem.WithUserMessage("Failed to pull evaluation")).ShowErrorAndExit() + } + } + + spinner.New().Title("Pulling evaluation...").Action(action).Run() + + // Write code to file + filename := evalObj.Name + ".ts" + evalsDir := filepath.Join(dir, "src", "evals") + + // Create the evals directory if it doesn't exist + if err := os.MkdirAll(evalsDir, 0755); err != nil { + errsystem.New(errsystem.ErrCreateDirectory, err, errsystem.WithUserMessage("Failed to create evals directory")).ShowErrorAndExit() + } + + filePath := filepath.Join(evalsDir, filename) + if err := os.WriteFile(filePath, []byte(evalObj.Code), 0644); err != nil { + errsystem.New(errsystem.ErrOpenFile, err, errsystem.WithUserMessage("Failed to write evaluation code to file")).ShowErrorAndExit() + } + + tui.ShowSuccess("Evaluation code written to: %s", filePath) + + // Output to stdout + fmt.Println(evalObj.Code) + }, +} + +func init() { + rootCmd.AddCommand(evalCmd) + + evalCreateCmd.Flags().Bool("force", !hasTTY, "Don't prompt for confirmation") + + evalCmd.AddCommand(evalCreateCmd) + evalCmd.AddCommand(evalPullCmd) + + for _, cmd := range []*cobra.Command{evalCreateCmd, evalPullCmd} { + cmd.Flags().StringP("dir", "d", ".", "The directory to the project") + } +} diff --git a/internal/bundler/vercel_ai.go b/internal/bundler/vercel_ai.go index 3fbea7c1..6c174e30 100644 --- a/internal/bundler/vercel_ai.go +++ b/internal/bundler/vercel_ai.go @@ -31,7 +31,13 @@ func createVercelAIProviderPatch(module string, createFn string, envkey string, } func init() { - var vercelTelemetryPatch = generateJSArgsPatch(0, `experimental_telemetry: { isEnabled: true }`) + var vercelTelemetryPatch = generateJSArgsPatch(0, ``) + fmt.Sprintf(` + const opts = {...(_args[0] ?? {}) }; + const metadata = { promptId: opts.prompt.id }; + opts.experimental_telemetry = { isEnabled: true , metadata: metadata }; + opts.prompt = opts.prompt.toString(); + _args[0] = opts; + `) vercelAIPatches := patchModule{ Module: "ai", Functions: map[string]patchAction{ From b56c092883cdf296149c93ba411eb32d2400f9dc Mon Sep 17 00:00:00 2001 From: Rick Blalock <211819+rblalock@users.noreply.github.com> Date: Mon, 15 Sep 2025 12:43:47 -0400 Subject: [PATCH 2/9] add prompts.yaml to bundle, push to cloud on deploy, etc. (#443) * add prompts.yaml to bundle * process prompts on deployment * Remove hash check since we're moving it to the API --- cmd/cloud.go | 7 ++ internal/bundler/bundler.go | 48 +++++++++++++- internal/prompts/prompts.go | 124 ++++++++++++++++++++++++++++++++++++ 3 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 internal/prompts/prompts.go diff --git a/cmd/cloud.go b/cmd/cloud.go index 00beab5d..d86ea54a 100644 --- a/cmd/cloud.go +++ b/cmd/cloud.go @@ -21,6 +21,7 @@ import ( "github.com/agentuity/cli/internal/errsystem" "github.com/agentuity/cli/internal/ignore" "github.com/agentuity/cli/internal/project" + "github.com/agentuity/cli/internal/prompts" "github.com/agentuity/cli/internal/util" "github.com/agentuity/go-common/crypto" "github.com/agentuity/go-common/env" @@ -450,6 +451,12 @@ Examples: orgSecret = *startResponse.Data.OrgSecret } + // Process prompts.yaml if present + if err := prompts.ProcessPrompts(ctx, logger, client, dir, startResponse.Data.DeploymentId); err != nil { + errsystem.New(errsystem.ErrDeployProject, err, + errsystem.WithContextMessage("Error processing prompts")).ShowErrorAndExit() + } + var saveProject bool // remove any agents that were deleted from the project diff --git a/internal/bundler/bundler.go b/internal/bundler/bundler.go index 9fe579b3..78a21f8b 100644 --- a/internal/bundler/bundler.go +++ b/internal/bundler/bundler.go @@ -376,6 +376,10 @@ func bundleJavascript(ctx BundleContext, dir string, outdir string, theproject * return nil // This line will never be reached due to os.Exit } + if err := copyPromptsFromSrc(ctx.Logger, dir, outdir); err != nil { + return fmt.Errorf("copy prompts.yaml: %w", err) + } + if err := validateDiskRequest(ctx, outdir); err != nil { return err } @@ -469,7 +473,49 @@ func bundlePython(ctx BundleContext, dir string, outdir string, theproject *proj } config["app"] = app } - return os.WriteFile(filepath.Join(outdir, "config.json"), []byte(cstr.JSONStringify(config)), 0644) + if err := os.WriteFile(filepath.Join(outdir, "config.json"), []byte(cstr.JSONStringify(config)), 0644); err != nil { + return err + } + + if err := copyPromptsFromSrc(ctx.Logger, dir, outdir); err != nil { + return fmt.Errorf("copy prompts.yaml: %w", err) + } + + return nil +} + +func copyPromptsFromSrc(logger logger.Logger, projectDir, outdir string) error { + srcRoot := filepath.Join(projectDir, "src") + if !util.Exists(srcRoot) { + return nil // nothing to do + } + + return filepath.Walk(srcRoot, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + name := strings.ToLower(info.Name()) + if name != "prompts.yaml" && name != "prompts.yml" { + return nil + } + + rel, err := filepath.Rel(projectDir, path) + if err != nil { + return err + } + destPath := filepath.Join(outdir, rel) + + if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil { + return err + } + + logger.Debug("copying prompts file: %s -> %s", path, destPath) + _, err = util.CopyFile(path, destPath) + return err + }) } func getAgents(theproject *project.Project, filename string) []AgentConfig { diff --git a/internal/prompts/prompts.go b/internal/prompts/prompts.go new file mode 100644 index 00000000..5957e7cb --- /dev/null +++ b/internal/prompts/prompts.go @@ -0,0 +1,124 @@ +package prompts + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/agentuity/cli/internal/util" + "github.com/agentuity/go-common/logger" + "gopkg.in/yaml.v3" +) + +type Prompt struct { + ID string `yaml:"id" json:"id"` + Name string `yaml:"name" json:"name"` + Description string `yaml:"description,omitempty" json:"description,omitempty"` + System string `yaml:"system,omitempty" json:"system,omitempty"` + Prompt string `yaml:"prompt,omitempty" json:"prompt,omitempty"` +} + +type PromptsFile struct { + Prompts []Prompt `yaml:"prompts" json:"prompts"` +} + +type PromptRequest struct { + Slug string `json:"slug"` + Content map[string]interface{} `json:"content"` +} + +type PromptsAPIRequest struct { + Prompts []PromptRequest `json:"prompts"` +} + +// ProcessPrompts reads the single prompts.yaml file from .agentuity bundle and sends it to the API +func ProcessPrompts(ctx context.Context, logger logger.Logger, client *util.APIClient, projectDir string, deploymentId string) error { + // Look for the single prompts.yaml file that was copied by bundler + promptsFile := filepath.Join(projectDir, ".agentuity", "src", "prompts.yaml") + if !util.Exists(promptsFile) { + logger.Debug("no prompts.yaml found in bundle, skipping prompts processing") + return nil + } + + prompts, err := parsePromptsFile(promptsFile) + if err != nil { + return fmt.Errorf("failed to parse prompts.yaml: %w", err) + } + + if len(prompts) == 0 { + logger.Debug("no prompts found in prompts.yaml, skipping prompts processing") + return nil + } + + // Validate prompts have required fields + for _, prompt := range prompts { + if prompt.ID == "" { + return fmt.Errorf("prompt missing required 'id' field") + } + if prompt.Name == "" { + return fmt.Errorf("prompt '%s' missing required 'name' field", prompt.ID) + } + // Either / or + if prompt.System == "" { + return fmt.Errorf("prompt '%s' missing required 'system' field", prompt.ID) + } + if prompt.Prompt == "" { + return fmt.Errorf("prompt '%s' missing required 'prompt' field", prompt.ID) + } + } + + // Convert to API request format + var apiRequest PromptsAPIRequest + for _, prompt := range prompts { + // Convert prompt to map for JSON serialization + contentBytes, err := json.Marshal(prompt) + if err != nil { + return fmt.Errorf("failed to marshal prompt %s: %w", prompt.ID, err) + } + + var content map[string]interface{} + if err := json.Unmarshal(contentBytes, &content); err != nil { + return fmt.Errorf("failed to unmarshal prompt %s content: %w", prompt.ID, err) + } + + apiRequest.Prompts = append(apiRequest.Prompts, PromptRequest{ + Slug: prompt.ID, + Content: content, + }) + + logger.Debug("processing prompt: %s", prompt.ID) + } + + // Send to API + endpoint := fmt.Sprintf("/cli/deploy/%s/prompts", deploymentId) + if err := client.Do("PUT", endpoint, apiRequest, nil); err != nil { + return fmt.Errorf("failed to process prompts via API: %w", err) + } + + logger.Info("processed %d prompts successfully", len(prompts)) + return nil +} + +// parsePromptsFile parses a single prompts.yaml file +func parsePromptsFile(filename string) ([]Prompt, error) { + file, err := os.Open(filename) + if err != nil { + return nil, err + } + defer file.Close() + + content, err := io.ReadAll(file) + if err != nil { + return nil, err + } + + var promptsFile PromptsFile + if err := yaml.Unmarshal(content, &promptsFile); err != nil { + return nil, fmt.Errorf("failed to parse YAML: %w", err) + } + + return promptsFile.Prompts, nil +} From 73f4c246b403bdd0281c56e055189dcd6edd01ba Mon Sep 17 00:00:00 2001 From: Rick Blalock Date: Mon, 15 Sep 2025 14:53:33 -0400 Subject: [PATCH 3/9] tweak prompt yaml requirements logic --- internal/prompts/prompts.go | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/internal/prompts/prompts.go b/internal/prompts/prompts.go index 5957e7cb..8232550b 100644 --- a/internal/prompts/prompts.go +++ b/internal/prompts/prompts.go @@ -61,12 +61,8 @@ func ProcessPrompts(ctx context.Context, logger logger.Logger, client *util.APIC if prompt.Name == "" { return fmt.Errorf("prompt '%s' missing required 'name' field", prompt.ID) } - // Either / or - if prompt.System == "" { - return fmt.Errorf("prompt '%s' missing required 'system' field", prompt.ID) - } - if prompt.Prompt == "" { - return fmt.Errorf("prompt '%s' missing required 'prompt' field", prompt.ID) + if prompt.System == "" && prompt.Prompt == "" { + return fmt.Errorf("prompt '%s' must have either 'system' or 'prompt' field", prompt.ID) } } From 6612b18f5c7790304c75850f70a0051c0e38b07b Mon Sep 17 00:00:00 2001 From: Rick Blalock Date: Mon, 15 Sep 2025 19:46:56 -0400 Subject: [PATCH 4/9] prompt new command --- cmd/prompt.go | 252 ++++++++++++++++++++++++++++++++++++ internal/prompts/prompts.go | 11 +- 2 files changed, 258 insertions(+), 5 deletions(-) create mode 100644 cmd/prompt.go diff --git a/cmd/prompt.go b/cmd/prompt.go new file mode 100644 index 00000000..20854ff4 --- /dev/null +++ b/cmd/prompt.go @@ -0,0 +1,252 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "os/signal" + "path/filepath" + "regexp" + "strings" + "syscall" + + "github.com/agentuity/cli/internal/errsystem" + "github.com/agentuity/cli/internal/prompts" + "github.com/agentuity/cli/internal/project" + "github.com/agentuity/cli/internal/util" + "github.com/agentuity/go-common/env" + "github.com/agentuity/go-common/tui" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +// PromptsYaml represents the full structure of src/prompts.yaml +type PromptsYaml struct { + Prompts []prompts.Prompt `yaml:"prompts"` +} + +// Slugify converts a name into a valid ID slug using dashes (kebab-case) +func Slugify(name string) string { + // Convert to lowercase and trim whitespace + slug := strings.ToLower(strings.TrimSpace(name)) + + // Replace non-alphanumeric characters with dashes + reg := regexp.MustCompile(`[^a-z0-9]+`) + slug = reg.ReplaceAllString(slug, "-") + + // Remove leading/trailing dashes and collapse multiple dashes + slug = strings.Trim(slug, "-") + reg = regexp.MustCompile(`-+`) + slug = reg.ReplaceAllString(slug, "-") + + return slug +} + +// generateUniqueID creates a unique ID by appending numbers if needed +func generateUniqueID(baseID string, existingPrompts []prompts.Prompt) string { + id := baseID + counter := 2 + + for { + // Check if ID already exists + exists := false + for _, prompt := range existingPrompts { + if prompt.ID == id { + exists = true + break + } + } + + if !exists { + return id + } + + // Try with counter + id = fmt.Sprintf("%s-%d", baseID, counter) + counter++ + } +} + +// readPromptsFile reads existing prompts.yaml or returns empty structure +func readPromptsFile(filePath string) (*PromptsYaml, error) { + if !util.Exists(filePath) { + // Return empty structure for new file + return &PromptsYaml{ + Prompts: []prompts.Prompt{}, + }, nil + } + + data, err := os.ReadFile(filePath) + if err != nil { + return nil, err + } + + var promptsYaml PromptsYaml + if err := yaml.Unmarshal(data, &promptsYaml); err != nil { + return nil, err + } + + return &promptsYaml, nil +} + +// writePromptsFile writes the prompts structure to YAML file +func writePromptsFile(filePath string, promptsYaml *PromptsYaml) error { + // Ensure src directory exists + srcDir := filepath.Dir(filePath) + if err := os.MkdirAll(srcDir, 0755); err != nil { + return err + } + + data, err := yaml.Marshal(promptsYaml) + if err != nil { + return err + } + + return os.WriteFile(filePath, data, 0644) +} + +var promptCmd = &cobra.Command{ + Use: "prompt", + Short: "Prompt related commands", + Long: `Prompt related commands for managing prompt templates. + +Use the subcommands to create and manage prompt templates in your project.`, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} + +var promptCreateCmd = &cobra.Command{ + Use: "create [name] [description]", + Short: "Create a new prompt template", + Long: `Create a new prompt template in src/prompts.yaml. + +This command will add a new prompt entry to your project's prompts.yaml file. +If the file doesn't exist, it will be created. The ID will be automatically +generated from the name using underscores. + +Arguments: + [name] Optional name for the prompt + [description] Optional description for the prompt + +Flags: + --force Don't prompt for confirmation + +Examples: + agentuity prompt create + agentuity prompt create "Product Helper" "Helps with product descriptions" + agentuity prompt create --force "My Prompt" "Description"`, + Run: func(cmd *cobra.Command, args []string) { + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + logger := env.NewLogger(cmd) + context := project.EnsureProject(ctx, cmd) + dir := context.Dir + + force, _ := cmd.Flags().GetBool("force") + + var name, description string + + // Get name and description from args or prompt + if len(args) > 0 { + name = args[0] + } + if len(args) > 1 { + description = args[1] + } + + // Interactive flow for name + if name == "" { + if !tui.HasTTY { + logger.Fatal("No TTY detected, please specify a prompt name from the command line") + } + name = tui.InputWithValidation(logger, "What should we name this prompt?", "The name helps identify the prompt template", 255, func(name string) error { + if name == "" { + return fmt.Errorf("prompt name cannot be empty") + } + return nil + }) + } + + // Interactive flow for description (optional) + if description == "" && tui.HasTTY { + description = tui.Input(logger, "How should we describe what this prompt does?", "The description is optional but helpful for understanding the prompt's purpose") + } + + // Interactive flow for system and prompt fields (optional) + var systemMsg, promptBody string + if tui.HasTTY { + systemMsg = tui.Input(logger, "Enter an optional SYSTEM message for this prompt", "Leave blank to skip; you can edit prompts.yaml later") + promptBody = tui.Input(logger, "Enter the USER prompt text", "Leave blank to skip; you can edit prompts.yaml later") + } + + // Generate ID from name + baseID := Slugify(name) + + // Read existing prompts file + promptsFile := filepath.Join(dir, "src", "prompts.yaml") + promptsYaml, err := readPromptsFile(promptsFile) + if err != nil { + errsystem.New(errsystem.ErrOpenFile, err, errsystem.WithUserMessage("Failed to read prompts file")).ShowErrorAndExit() + } + + // Generate unique ID + uniqueID := generateUniqueID(baseID, promptsYaml.Prompts) + + // Confirm create unless force flag is set + if !force { + confirmMessage := fmt.Sprintf("Create prompt '%s' (%s) in src/prompts.yaml?", name, uniqueID) + if !tui.Ask(logger, confirmMessage, true) { + tui.ShowWarning("cancelled") + return + } + } + + // Create new prompt with collected system and prompt fields + newPrompt := prompts.Prompt{ + ID: uniqueID, + Name: name, + Description: description, + System: systemMsg, + Prompt: promptBody, + } + + // Add to prompts array + promptsYaml.Prompts = append(promptsYaml.Prompts, newPrompt) + + // Write back to file + if err := writePromptsFile(promptsFile, promptsYaml); err != nil { + errsystem.New(errsystem.ErrOpenFile, err, errsystem.WithUserMessage("Failed to write prompts file")).ShowErrorAndExit() + } + + // Get absolute path for display + absPath, err := filepath.Abs(promptsFile) + if err != nil { + absPath = promptsFile + } + + tui.ShowSuccess("Prompt '%s' (%s) created successfully", name, uniqueID) + + // Show next steps guidance + nextSteps := "1. Review the prompt in " + absPath + "\n" + if systemMsg == "" || promptBody == "" { + nextSteps += "2. Fill in any missing 'system' or 'prompt' fields\n" + } + nextSteps += "3. Add the prompt ID to your agent code" + + tui.ShowBanner("Next steps", nextSteps, false) + }, +} + +func init() { + rootCmd.AddCommand(promptCmd) + + promptCreateCmd.Flags().Bool("force", !hasTTY, "Don't prompt for confirmation") + + promptCmd.AddCommand(promptCreateCmd) + + for _, cmd := range []*cobra.Command{promptCreateCmd} { + cmd.Flags().StringP("dir", "d", ".", "The directory to the project") + } +} diff --git a/internal/prompts/prompts.go b/internal/prompts/prompts.go index 8232550b..72e2f811 100644 --- a/internal/prompts/prompts.go +++ b/internal/prompts/prompts.go @@ -14,11 +14,12 @@ import ( ) type Prompt struct { - ID string `yaml:"id" json:"id"` - Name string `yaml:"name" json:"name"` - Description string `yaml:"description,omitempty" json:"description,omitempty"` - System string `yaml:"system,omitempty" json:"system,omitempty"` - Prompt string `yaml:"prompt,omitempty" json:"prompt,omitempty"` + ID string `yaml:"id" json:"id"` + Name string `yaml:"name" json:"name"` + Description string `yaml:"description,omitempty" json:"description,omitempty"` + System string `yaml:"system,omitempty" json:"system,omitempty"` + Prompt string `yaml:"prompt,omitempty" json:"prompt,omitempty"` + Eval []string `yaml:"eval,omitempty" json:"eval,omitempty"` } type PromptsFile struct { From ec4370965ca48f3091d7fb35b2711a6709557677 Mon Sep 17 00:00:00 2001 From: Rick Blalock Date: Mon, 15 Sep 2025 20:47:48 -0400 Subject: [PATCH 5/9] id to slug --- cmd/prompt.go | 34 +++++++++++++++++----------------- internal/prompts/prompts.go | 18 +++++++++--------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/cmd/prompt.go b/cmd/prompt.go index 20854ff4..ef299057 100644 --- a/cmd/prompt.go +++ b/cmd/prompt.go @@ -42,27 +42,27 @@ func Slugify(name string) string { return slug } -// generateUniqueID creates a unique ID by appending numbers if needed -func generateUniqueID(baseID string, existingPrompts []prompts.Prompt) string { - id := baseID +// generateUniqueSlug creates a unique slug by appending numbers if needed +func generateUniqueSlug(baseSlug string, existingPrompts []prompts.Prompt) string { + slug := baseSlug counter := 2 for { - // Check if ID already exists + // Check if slug already exists exists := false for _, prompt := range existingPrompts { - if prompt.ID == id { + if prompt.Slug == slug { exists = true break } } if !exists { - return id + return slug } // Try with counter - id = fmt.Sprintf("%s-%d", baseID, counter) + slug = fmt.Sprintf("%s-%d", baseSlug, counter) counter++ } } @@ -122,8 +122,8 @@ var promptCreateCmd = &cobra.Command{ Long: `Create a new prompt template in src/prompts.yaml. This command will add a new prompt entry to your project's prompts.yaml file. -If the file doesn't exist, it will be created. The ID will be automatically -generated from the name using underscores. +If the file doesn't exist, it will be created. The slug will be automatically +generated from the name using dashes (kebab-case). Arguments: [name] Optional name for the prompt @@ -181,8 +181,8 @@ Examples: promptBody = tui.Input(logger, "Enter the USER prompt text", "Leave blank to skip; you can edit prompts.yaml later") } - // Generate ID from name - baseID := Slugify(name) + // Generate slug from name + baseSlug := Slugify(name) // Read existing prompts file promptsFile := filepath.Join(dir, "src", "prompts.yaml") @@ -191,12 +191,12 @@ Examples: errsystem.New(errsystem.ErrOpenFile, err, errsystem.WithUserMessage("Failed to read prompts file")).ShowErrorAndExit() } - // Generate unique ID - uniqueID := generateUniqueID(baseID, promptsYaml.Prompts) + // Generate unique slug + uniqueSlug := generateUniqueSlug(baseSlug, promptsYaml.Prompts) // Confirm create unless force flag is set if !force { - confirmMessage := fmt.Sprintf("Create prompt '%s' (%s) in src/prompts.yaml?", name, uniqueID) + confirmMessage := fmt.Sprintf("Create prompt '%s' (%s) in src/prompts.yaml?", name, uniqueSlug) if !tui.Ask(logger, confirmMessage, true) { tui.ShowWarning("cancelled") return @@ -205,7 +205,7 @@ Examples: // Create new prompt with collected system and prompt fields newPrompt := prompts.Prompt{ - ID: uniqueID, + Slug: uniqueSlug, Name: name, Description: description, System: systemMsg, @@ -226,14 +226,14 @@ Examples: absPath = promptsFile } - tui.ShowSuccess("Prompt '%s' (%s) created successfully", name, uniqueID) + tui.ShowSuccess("Prompt '%s' (%s) created successfully", name, uniqueSlug) // Show next steps guidance nextSteps := "1. Review the prompt in " + absPath + "\n" if systemMsg == "" || promptBody == "" { nextSteps += "2. Fill in any missing 'system' or 'prompt' fields\n" } - nextSteps += "3. Add the prompt ID to your agent code" + nextSteps += "3. Add the prompt slug to your agent code" tui.ShowBanner("Next steps", nextSteps, false) }, diff --git a/internal/prompts/prompts.go b/internal/prompts/prompts.go index 72e2f811..d3a551bf 100644 --- a/internal/prompts/prompts.go +++ b/internal/prompts/prompts.go @@ -14,7 +14,7 @@ import ( ) type Prompt struct { - ID string `yaml:"id" json:"id"` + Slug string `yaml:"slug" json:"slug"` Name string `yaml:"name" json:"name"` Description string `yaml:"description,omitempty" json:"description,omitempty"` System string `yaml:"system,omitempty" json:"system,omitempty"` @@ -56,14 +56,14 @@ func ProcessPrompts(ctx context.Context, logger logger.Logger, client *util.APIC // Validate prompts have required fields for _, prompt := range prompts { - if prompt.ID == "" { - return fmt.Errorf("prompt missing required 'id' field") + if prompt.Slug == "" { + return fmt.Errorf("prompt missing required 'slug' field") } if prompt.Name == "" { - return fmt.Errorf("prompt '%s' missing required 'name' field", prompt.ID) + return fmt.Errorf("prompt '%s' missing required 'name' field", prompt.Slug) } if prompt.System == "" && prompt.Prompt == "" { - return fmt.Errorf("prompt '%s' must have either 'system' or 'prompt' field", prompt.ID) + return fmt.Errorf("prompt '%s' must have either 'system' or 'prompt' field", prompt.Slug) } } @@ -73,20 +73,20 @@ func ProcessPrompts(ctx context.Context, logger logger.Logger, client *util.APIC // Convert prompt to map for JSON serialization contentBytes, err := json.Marshal(prompt) if err != nil { - return fmt.Errorf("failed to marshal prompt %s: %w", prompt.ID, err) + return fmt.Errorf("failed to marshal prompt %s: %w", prompt.Slug, err) } var content map[string]interface{} if err := json.Unmarshal(contentBytes, &content); err != nil { - return fmt.Errorf("failed to unmarshal prompt %s content: %w", prompt.ID, err) + return fmt.Errorf("failed to unmarshal prompt %s content: %w", prompt.Slug, err) } apiRequest.Prompts = append(apiRequest.Prompts, PromptRequest{ - Slug: prompt.ID, + Slug: prompt.Slug, Content: content, }) - logger.Debug("processing prompt: %s", prompt.ID) + logger.Debug("processing prompt: %s", prompt.Slug) } // Send to API From 3efaae69a2864fb4a9ab869c371a59e898e1aad6 Mon Sep 17 00:00:00 2001 From: Bobby Christopher Date: Tue, 16 Sep 2025 10:41:35 -0400 Subject: [PATCH 6/9] added prompt patch --- internal/bundler/agentuity_prompts.go | 956 +++++++++++++++++++++ internal/bundler/agentuity_prompts_test.go | 185 ++++ internal/bundler/bundler.go | 16 +- internal/prompts/prompts.go | 24 +- 4 files changed, 1163 insertions(+), 18 deletions(-) create mode 100644 internal/bundler/agentuity_prompts.go create mode 100644 internal/bundler/agentuity_prompts_test.go diff --git a/internal/bundler/agentuity_prompts.go b/internal/bundler/agentuity_prompts.go new file mode 100644 index 00000000..4c1771ed --- /dev/null +++ b/internal/bundler/agentuity_prompts.go @@ -0,0 +1,956 @@ +package bundler + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/agentuity/cli/internal/prompts" + "github.com/agentuity/go-common/logger" +) + +// generatePromptsInjection creates the JavaScript code to inject prompt methods into the server +func generatePromptsInjection(logger logger.Logger, projectDir string) string { + // Find all prompts.yaml files in the source directory + srcRoot := filepath.Join(projectDir, "src") + var allPrompts []prompts.Prompt + + // Walk through src directory to find all prompts.yaml files + err := filepath.Walk(srcRoot, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + + name := strings.ToLower(info.Name()) + if name != "prompts.yaml" && name != "prompts.yml" { + return nil + } + + // Parse the YAML file + filePrompts, err := prompts.ParsePromptsFile(path) + if err != nil { + logger.Warn("failed to parse %s: %v", path, err) + return nil // Continue processing other files + } + + allPrompts = append(allPrompts, filePrompts...) + return nil + }) + + if err != nil { + logger.Warn("failed to walk src directory: %v", err) + return "" + } + + if len(allPrompts) == 0 { + logger.Debug("no prompts found, skipping PromptAPI injection") + return "" + } + + var sb strings.Builder + + // Generate the prompts data object + sb.WriteString("// Auto-generated prompts from YAML files\n") + sb.WriteString("const AGENTUITY_PROMPTS = {\n") + for i, prompt := range allPrompts { + if i > 0 { + sb.WriteString(",\n") + } + sb.WriteString(fmt.Sprintf("\t'%s': {\n", escapeString(prompt.Slug))) + sb.WriteString(fmt.Sprintf("\t\tslug: '%s',\n", escapeString(prompt.Slug))) + sb.WriteString(fmt.Sprintf("\t\tname: '%s',\n", escapeString(prompt.Name))) + if prompt.Description != "" { + sb.WriteString(fmt.Sprintf("\t\tdescription: '%s',\n", escapeString(prompt.Description))) + } + sb.WriteString(fmt.Sprintf("\t\tsystem: `%s`,\n", escapeTemplateString(prompt.System))) + sb.WriteString(fmt.Sprintf("\t\tprompt: `%s`\n", escapeTemplateString(prompt.Prompt))) + sb.WriteString("\t}") + } + sb.WriteString("\n};\n\n") + + // Helper function to convert slug to camelCase method name + sb.WriteString("function toCamelCase(slug) {\n") + sb.WriteString("\treturn slug.replace(/-([a-z])/g, (g) => g[1].toUpperCase());\n") + sb.WriteString("}\n\n") + + // Helper function to fill template variables + sb.WriteString("function fillTemplate(template, variables = {}) {\n") + sb.WriteString("\tlet filled = template;\n") + sb.WriteString("\tfor (const [key, value] of Object.entries(variables)) {\n") + sb.WriteString("\t\tconst regex = new RegExp(`{{\\\\s*${key}\\\\s*}}`, 'g');\n") + sb.WriteString("\t\tfilled = filled.replace(regex, String(value));\n") + sb.WriteString("\t}\n") + sb.WriteString("\treturn filled;\n") + sb.WriteString("}\n\n") + + // Generate TypeScript method signatures to be appended to PromptService interface + sb.WriteString("\n\t// Auto-generated prompt methods - DO NOT EDIT\n") + for _, prompt := range allPrompts { + methodName := toCamelCase(prompt.Slug) + sb.WriteString(fmt.Sprintf("\t/**\n")) + sb.WriteString(fmt.Sprintf("\t * %s\n", escapeString(prompt.Name))) + if prompt.Description != "" { + sb.WriteString(fmt.Sprintf("\t * %s\n", escapeString(prompt.Description))) + } + sb.WriteString(fmt.Sprintf("\t * @param variables - Variables to substitute in the prompt template\n")) + sb.WriteString(fmt.Sprintf("\t * @returns Prompt definition with filled template\n")) + sb.WriteString(fmt.Sprintf("\t */\n")) + sb.WriteString(fmt.Sprintf("\t%s(variables?: Record): PromptDefinition;\n\n", methodName)) + } + + logger.Debug("generated PromptAPI injection for %d prompts", len(allPrompts)) + return sb.String() +} + +// escapeString escapes a string for use in TypeScript string literals +func escapeString(s string) string { + // Replace single quotes and backslashes + s = strings.ReplaceAll(s, "\\", "\\\\") + s = strings.ReplaceAll(s, "'", "\\'") + s = strings.ReplaceAll(s, "\n", "\\n") + s = strings.ReplaceAll(s, "\r", "\\r") + s = strings.ReplaceAll(s, "\t", "\\t") + return s +} + +// escapeTemplateString escapes a string for use in TypeScript template literals +func escapeTemplateString(s string) string { + // Replace backticks and dollar signs that could interfere with template literals + s = strings.ReplaceAll(s, "\\", "\\\\") + s = strings.ReplaceAll(s, "`", "\\`") + s = strings.ReplaceAll(s, "${", "\\${") + return s +} + +// toCamelCase converts a kebab-case string to camelCase +func toCamelCase(slug string) string { + parts := strings.Split(slug, "-") + if len(parts) <= 1 { + return slug + } + + result := parts[0] + for i := 1; i < len(parts); i++ { + if len(parts[i]) > 0 { + result += strings.ToUpper(string(parts[i][0])) + parts[i][1:] + } + } + return result +} + +func init() { + // We'll patch the types.ts file to inject prompt method definitions into PromptService interface + patches["@agentuity/sdk-prompts-types"] = patchModule{ + Module: "@agentuity/sdk", + Filename: "", // Match all files in the module + Body: &patchAction{ + // We'll populate this during bundling with the actual prompt data + After: "", // Will be set dynamically during bundle + }, + } +} + +// updatePromptsPatches updates the prompts patch with current project data +func updatePromptsPatches(logger logger.Logger, projectDir string) { + injection := generatePromptsInjection(logger, projectDir) + + if injection != "" { + // Update the types patch + if patch, exists := patches["@agentuity/sdk-prompts-types"]; exists { + patch.Body.After = injection + patches["@agentuity/sdk-prompts-types"] = patch + } + } +} + +// generatePromptsTypeScript creates TypeScript declaration files for prompts +func generatePromptsTypeScript(logger logger.Logger, projectDir, outdir string) error { + // Find all prompts.yaml files in the source directory + srcRoot := filepath.Join(projectDir, "src") + var allPrompts []prompts.Prompt + + // Walk through src directory to find all prompts.yaml files + err := filepath.Walk(srcRoot, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + + name := strings.ToLower(info.Name()) + if name != "prompts.yaml" && name != "prompts.yml" { + return nil + } + + // Parse the YAML file + filePrompts, err := prompts.ParsePromptsFile(path) + if err != nil { + logger.Warn("failed to parse %s: %v", path, err) + return nil // Continue processing other files + } + + allPrompts = append(allPrompts, filePrompts...) + return nil + }) + + if err != nil { + return fmt.Errorf("failed to walk src directory: %w", err) + } + + if len(allPrompts) == 0 { + logger.Debug("no prompts found, skipping TypeScript generation") + return nil + } + + var sb strings.Builder + + // Generate TypeScript module augmentation + sb.WriteString("// Auto-generated prompt definitions - DO NOT EDIT\n") + sb.WriteString("// Generated at build time by agentuity bundler\n\n") + sb.WriteString("declare module '@agentuity/sdk' {\n") + sb.WriteString("\tinterface PromptService {\n") + + for _, prompt := range allPrompts { + methodName := toCamelCase(prompt.Slug) + sb.WriteString(fmt.Sprintf("\t\t/**\n")) + sb.WriteString(fmt.Sprintf("\t\t * %s\n", escapeString(prompt.Name))) + if prompt.Description != "" { + sb.WriteString(fmt.Sprintf("\t\t * %s\n", escapeString(prompt.Description))) + } + sb.WriteString(fmt.Sprintf("\t\t * @param variables - Variables to substitute in the prompt template\n")) + sb.WriteString(fmt.Sprintf("\t\t * @returns Prompt definition with filled template\n")) + sb.WriteString(fmt.Sprintf("\t\t */\n")) + sb.WriteString(fmt.Sprintf("\t\t%s(variables?: Record): PromptDefinition;\n\n", methodName)) + } + + sb.WriteString("\t}\n") + sb.WriteString("}\n") + + // Write TypeScript declaration file to project root + declPath := filepath.Join(projectDir, "agentuity-prompts.d.ts") + if err := os.WriteFile(declPath, []byte(sb.String()), 0644); err != nil { + return fmt.Errorf("failed to write TypeScript declaration: %w", err) + } + + logger.Debug("generated TypeScript declarations for %d prompts at %s", len(allPrompts), declPath) + return nil +} + +// generateRuntimeInjection creates the JavaScript code to inject runtime implementations +func generateRuntimeInjection(logger logger.Logger, projectDir string) string { + // Find all prompts.yaml files in the source directory + srcRoot := filepath.Join(projectDir, "src") + var allPrompts []prompts.Prompt + + // Walk through src directory to find all prompts.yaml files + err := filepath.Walk(srcRoot, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + + name := strings.ToLower(info.Name()) + if name != "prompts.yaml" && name != "prompts.yml" { + return nil + } + + // Parse the YAML file + filePrompts, err := prompts.ParsePromptsFile(path) + if err != nil { + logger.Warn("failed to parse %s: %v", path, err) + return nil // Continue processing other files + } + + allPrompts = append(allPrompts, filePrompts...) + return nil + }) + + if err != nil { + logger.Warn("failed to walk src directory: %v", err) + return "" + } + + if len(allPrompts) == 0 { + logger.Debug("no prompts found, skipping runtime injection") + return "" + } + + var sb strings.Builder + + // Generate the prompts data object + sb.WriteString("\n// Auto-generated prompts from YAML files\n") + sb.WriteString("const AGENTUITY_PROMPTS = {\n") + for i, prompt := range allPrompts { + if i > 0 { + sb.WriteString(",\n") + } + sb.WriteString(fmt.Sprintf("\t'%s': {\n", escapeString(prompt.Slug))) + sb.WriteString(fmt.Sprintf("\t\tslug: '%s',\n", escapeString(prompt.Slug))) + sb.WriteString(fmt.Sprintf("\t\tname: '%s',\n", escapeString(prompt.Name))) + if prompt.Description != "" { + sb.WriteString(fmt.Sprintf("\t\tdescription: '%s',\n", escapeString(prompt.Description))) + } + sb.WriteString(fmt.Sprintf("\t\tsystem: `%s`,\n", escapeTemplateString(prompt.System))) + sb.WriteString(fmt.Sprintf("\t\tprompt: `%s`\n", escapeTemplateString(prompt.Prompt))) + sb.WriteString("\t}") + } + sb.WriteString("\n};\n\n") + + // Helper function to fill template variables + sb.WriteString("function fillTemplate(template, variables = {}) {\n") + sb.WriteString("\tlet filled = template;\n") + sb.WriteString("\tfor (const [key, value] of Object.entries(variables)) {\n") + sb.WriteString("\t\tconst regex = new RegExp(`{{\\\\s*${key}\\\\s*}}`, 'g');\n") + sb.WriteString("\t\tfilled = filled.replace(regex, String(value));\n") + sb.WriteString("\t}\n") + sb.WriteString("\treturn filled;\n") + sb.WriteString("}\n\n") + + // Inject dynamic methods into the prompt instance + sb.WriteString("// Inject dynamic prompt methods\n") + for _, prompt := range allPrompts { + methodName := toCamelCase(prompt.Slug) + sb.WriteString(fmt.Sprintf("prompt.%s = function(variables = {}) {\n", methodName)) + sb.WriteString(fmt.Sprintf("\tconst promptDef = AGENTUITY_PROMPTS['%s'];\n", escapeString(prompt.Slug))) + sb.WriteString("\treturn {\n") + sb.WriteString("\t\tslug: promptDef.slug,\n") + sb.WriteString("\t\tname: promptDef.name,\n") + sb.WriteString("\t\tdescription: promptDef.description,\n") + sb.WriteString("\t\tsystem: fillTemplate(promptDef.system, variables),\n") + sb.WriteString("\t\tprompt: fillTemplate(promptDef.prompt, variables)\n") + sb.WriteString("\t};\n") + sb.WriteString("};\n\n") + } + + logger.Debug("generated runtime injection for %d prompts", len(allPrompts)) + return sb.String() +} + +// patchSDKFiles directly modifies the SDK files in node_modules +func patchSDKFiles(logger logger.Logger, projectDir string) error { + // Find all prompts.yaml files in the source directory + srcRoot := filepath.Join(projectDir, "src") + var allPrompts []prompts.Prompt + + // Walk through src directory to find all prompts.yaml files + err := filepath.Walk(srcRoot, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + + name := strings.ToLower(info.Name()) + if name != "prompts.yaml" && name != "prompts.yml" { + return nil + } + + // Parse the YAML file + filePrompts, err := prompts.ParsePromptsFile(path) + if err != nil { + logger.Warn("failed to parse %s: %v", path, err) + return nil // Continue processing other files + } + + allPrompts = append(allPrompts, filePrompts...) + return nil + }) + + if err != nil { + return fmt.Errorf("failed to walk src directory: %w", err) + } + + if len(allPrompts) == 0 { + logger.Debug("no prompts found, skipping SDK patching") + return nil + } + + // Patch dist/types.d.ts file (compiled declarations that TypeScript actually reads) + distTypesPath := filepath.Join(projectDir, "node_modules", "@agentuity", "sdk", "dist", "types.d.ts") + if err := patchDistTypesFile(logger, distTypesPath, allPrompts); err != nil { + logger.Warn("failed to patch dist/types.d.ts: %v (TypeScript autocomplete may not work)", err) + } + + // Patch the dist/apis/prompt.d.ts file for TypeScript support + promptTypesPath := filepath.Join(projectDir, "node_modules", "@agentuity", "sdk", "dist", "apis", "prompt.d.ts") + if err := patchPromptTypesFile(logger, promptTypesPath, allPrompts); err != nil { + logger.Warn("failed to patch prompt.d.ts: %v (TypeScript autocomplete may not work)", err) + } + + // Patch the compiled JavaScript file where the actual runtime code lives + distJSPath := filepath.Join(projectDir, "node_modules", "@agentuity", "sdk", "dist", "index.js") + if err := patchDistJSFile(logger, distJSPath, allPrompts); err != nil { + logger.Warn("failed to patch dist/index.js: %v (runtime functions may not work)", err) + } + + logger.Debug("successfully patched SDK files for %d prompts", len(allPrompts)) + return nil +} + +// patchPromptTypesFile adds method signatures to the prompt API declaration file +func patchPromptTypesFile(logger logger.Logger, filePath string, allPrompts []prompts.Prompt) error { + // Read the existing file + content, err := os.ReadFile(filePath) + if err != nil { + return err + } + + originalContent := string(content) + + // Remove any existing auto-generated content + if strings.Contains(originalContent, "// Auto-generated prompt methods") { + // Find and remove the existing auto-generated section + startMarker := " // Auto-generated prompt methods - DO NOT EDIT\n" + start := strings.Index(originalContent, startMarker) + if start != -1 { + endMarker := " // End auto-generated prompt methods\n" + end := strings.Index(originalContent[start:], endMarker) + if end != -1 { + end += start + len(endMarker) + originalContent = originalContent[:start] + originalContent[end:] + } + } + } + + // Generate new method signatures for prompts + var methodSignatures strings.Builder + methodSignatures.WriteString(" // Auto-generated prompt methods - DO NOT EDIT\n") + + for _, prompt := range allPrompts { + methodName := toCamelCase(prompt.Slug) + methodSignatures.WriteString(fmt.Sprintf(" /**\n")) + methodSignatures.WriteString(fmt.Sprintf(" * %s\n", prompt.Description)) + methodSignatures.WriteString(fmt.Sprintf(" * @param variables - Template variables to substitute\n")) + methodSignatures.WriteString(fmt.Sprintf(" * @returns The compiled prompt as a string with attached metadata properties\n")) + methodSignatures.WriteString(fmt.Sprintf(" */\n")) + methodSignatures.WriteString(fmt.Sprintf(" %s(variables?: Record): Promise }>;\n", methodName)) + } + + methodSignatures.WriteString(" // End auto-generated prompt methods\n") + + // Find the PromptAPI class and insert methods before the closing brace + classStart := strings.Index(originalContent, "export default class PromptAPI") + if classStart == -1 { + return fmt.Errorf("could not find PromptAPI class definition") + } + + // Find the last method in the class (should be the compile method) + compileMethodEnd := strings.Index(originalContent[classStart:], " }): Promise;") + if compileMethodEnd == -1 { + return fmt.Errorf("could not find compile method end") + } + compileMethodEnd += classStart + len(" }): Promise;") + 1 + + // Find the closing brace of the class + classEnd := strings.Index(originalContent[compileMethodEnd:], "}") + if classEnd == -1 { + return fmt.Errorf("could not find class closing brace") + } + classEnd += compileMethodEnd + + // Insert the new methods before the class closing brace + newContent := originalContent[:classEnd] + methodSignatures.String() + originalContent[classEnd:] + + // Write back to file + if err := os.WriteFile(filePath, []byte(newContent), 0644); err != nil { + return err + } + + logger.Debug("patched prompt.d.ts with %d method signatures", len(allPrompts)) + return nil +} + +// patchDistTypesFile adds method signatures to the compiled declaration file +func patchDistTypesFile(logger logger.Logger, filePath string, allPrompts []prompts.Prompt) error { + // Read the existing file + content, err := os.ReadFile(filePath) + if err != nil { + return err + } + + originalContent := string(content) + + // Remove any existing auto-generated content + if strings.Contains(originalContent, "// Auto-generated prompt methods") { + // Find and remove the existing auto-generated section + startMarker := "\n // Auto-generated prompt methods - DO NOT EDIT\n" + start := strings.Index(originalContent, startMarker) + if start != -1 { + // Find the end of the PromptService interface + afterStart := start + len(startMarker) + end := strings.Index(originalContent[afterStart:], "\n}") + if end != -1 { + // Remove the old auto-generated section + originalContent = originalContent[:start] + originalContent[afterStart+end:] + logger.Debug("removed existing auto-generated methods from dist/index.d.ts") + } + } + } + + // Find the PromptService interface + promptServiceStart := strings.Index(originalContent, "interface PromptService") + if promptServiceStart == -1 { + return fmt.Errorf("could not find PromptService interface in dist file") + } + + // Find the closing brace of PromptService interface + braceSearch := promptServiceStart + openBraces := 0 + interfaceEnd := -1 + + for i := braceSearch; i < len(originalContent); i++ { + if originalContent[i] == '{' { + openBraces++ + } else if originalContent[i] == '}' { + openBraces-- + if openBraces == 0 { + interfaceEnd = i + break + } + } + } + + if interfaceEnd == -1 { + return fmt.Errorf("could not find PromptService interface closing brace") + } + + // Generate method signatures + var sb strings.Builder + sb.WriteString("\n // Auto-generated prompt methods - DO NOT EDIT\n") + for _, prompt := range allPrompts { + methodName := toCamelCase(prompt.Slug) + sb.WriteString(fmt.Sprintf(" /**\n")) + sb.WriteString(fmt.Sprintf(" * %s\n", escapeString(prompt.Name))) + if prompt.Description != "" { + sb.WriteString(fmt.Sprintf(" * %s\n", escapeString(prompt.Description))) + } + sb.WriteString(fmt.Sprintf(" * @param variables - Variables to substitute in the prompt template\n")) + sb.WriteString(fmt.Sprintf(" * @returns Prompt definition with filled template\n")) + sb.WriteString(fmt.Sprintf(" */\n")) + sb.WriteString(fmt.Sprintf(" %s(variables?: Record): PromptDefinition;\n", methodName)) + } + + // Insert the methods before the closing brace + newContent := originalContent[:interfaceEnd] + sb.String() + originalContent[interfaceEnd:] + + // Write back to file + if err := os.WriteFile(filePath, []byte(newContent), 0644); err != nil { + return err + } + + logger.Debug("patched dist/index.d.ts with %d prompt methods", len(allPrompts)) + return nil +} + +// patchTypesFile adds method signatures to the PromptService interface +func patchTypesFile(logger logger.Logger, filePath string, allPrompts []prompts.Prompt) error { + // Read the existing file + content, err := os.ReadFile(filePath) + if err != nil { + return err + } + + originalContent := string(content) + + // Remove any existing auto-generated content + if strings.Contains(originalContent, "// Auto-generated prompt methods") { + // Find and remove the existing auto-generated section + startMarker := "\n\t// Auto-generated prompt methods - DO NOT EDIT\n" + endMarker := "\n}" + + start := strings.Index(originalContent, startMarker) + if start != -1 { + // Find the end of the PromptService interface (look for the next "}") + afterStart := start + len(startMarker) + end := strings.Index(originalContent[afterStart:], endMarker) + if end != -1 { + // Remove the old auto-generated section + originalContent = originalContent[:start] + originalContent[afterStart+end:] + logger.Debug("removed existing auto-generated methods from types.ts") + } + } + } + + // Find the PromptService interface closing brace + promptServiceEnd := strings.Index(originalContent, "// Dynamic methods will be injected here for each prompt") + if promptServiceEnd == -1 { + return fmt.Errorf("could not find injection point in PromptService interface") + } + + // Find the actual closing brace after the comment + braceStart := promptServiceEnd + len("// Dynamic methods will be injected here for each prompt") + nextBrace := strings.Index(originalContent[braceStart:], "}") + if nextBrace == -1 { + return fmt.Errorf("could not find PromptService interface closing brace") + } + bracePos := braceStart + nextBrace + + // Generate method signatures + var sb strings.Builder + sb.WriteString("\n\t// Auto-generated prompt methods - DO NOT EDIT\n") + for _, prompt := range allPrompts { + methodName := toCamelCase(prompt.Slug) + sb.WriteString(fmt.Sprintf("\t/**\n")) + sb.WriteString(fmt.Sprintf("\t * %s\n", escapeString(prompt.Name))) + if prompt.Description != "" { + sb.WriteString(fmt.Sprintf("\t * %s\n", escapeString(prompt.Description))) + } + sb.WriteString(fmt.Sprintf("\t * @param variables - Variables to substitute in the prompt template\n")) + sb.WriteString(fmt.Sprintf("\t * @returns Prompt definition with filled template\n")) + sb.WriteString(fmt.Sprintf("\t */\n")) + sb.WriteString(fmt.Sprintf("\t%s(variables?: Record): PromptDefinition;\n\n", methodName)) + } + + // Insert the methods before the closing brace + newContent := originalContent[:bracePos] + sb.String() + originalContent[bracePos:] + + // Write back to file + if err := os.WriteFile(filePath, []byte(newContent), 0644); err != nil { + return err + } + + logger.Debug("patched types.ts with %d prompt methods", len(allPrompts)) + return nil +} + +// patchServerFile adds runtime implementations after the prompt instance creation +func patchServerFile(logger logger.Logger, filePath string, allPrompts []prompts.Prompt) error { + // Read the existing file + content, err := os.ReadFile(filePath) + if err != nil { + return err + } + + originalContent := string(content) + + // Remove any existing auto-generated content + if strings.Contains(originalContent, "// Auto-generated prompts from YAML files") { + // Find and remove everything from the marker to the next function definition + startMarker := "\n// Auto-generated prompts from YAML files\n" + start := strings.Index(originalContent, startMarker) + if start != -1 { + // Find the next function or export statement to know where auto-generated content ends + afterStart := start + len(startMarker) + patterns := []string{"\n/**\n * Creates an agent context", "\nexport function", "\nfunction "} + + var end int = -1 + for _, pattern := range patterns { + if idx := strings.Index(originalContent[afterStart:], pattern); idx != -1 { + end = idx + break + } + } + + if end != -1 { + // Remove the old auto-generated section + originalContent = originalContent[:start] + originalContent[afterStart+end:] + logger.Debug("removed existing auto-generated content from server.ts") + } + } + } + + // Find the line after "const prompt = new PromptAPI();" + promptCreation := strings.Index(originalContent, "const prompt = new PromptAPI();") + if promptCreation == -1 { + return fmt.Errorf("could not find 'const prompt = new PromptAPI();' line") + } + + // Find the end of the line + lineEnd := strings.Index(originalContent[promptCreation:], "\n") + if lineEnd == -1 { + return fmt.Errorf("could not find end of prompt creation line") + } + insertPos := promptCreation + lineEnd + 1 + + // Generate runtime implementation + var sb strings.Builder + sb.WriteString("\n// Auto-generated prompts from YAML files\n") + sb.WriteString("const AGENTUITY_PROMPTS = {\n") + for i, prompt := range allPrompts { + if i > 0 { + sb.WriteString(",\n") + } + sb.WriteString(fmt.Sprintf("\t'%s': {\n", escapeString(prompt.Slug))) + sb.WriteString(fmt.Sprintf("\t\tslug: '%s',\n", escapeString(prompt.Slug))) + sb.WriteString(fmt.Sprintf("\t\tname: '%s',\n", escapeString(prompt.Name))) + if prompt.Description != "" { + sb.WriteString(fmt.Sprintf("\t\tdescription: '%s',\n", escapeString(prompt.Description))) + } + sb.WriteString(fmt.Sprintf("\t\tsystem: `%s`,\n", escapeTemplateString(prompt.System))) + sb.WriteString(fmt.Sprintf("\t\tprompt: `%s`\n", escapeTemplateString(prompt.Prompt))) + sb.WriteString("\t}") + } + sb.WriteString("\n};\n\n") + + // Helper function to fill template variables + sb.WriteString("function fillTemplate(template, variables = {}) {\n") + sb.WriteString("\tlet filled = template;\n") + sb.WriteString("\tfor (const [key, value] of Object.entries(variables)) {\n") + sb.WriteString("\t\tconst regex = new RegExp(`{{\\\\s*${key}\\\\s*}}`, 'g');\n") + sb.WriteString("\t\tfilled = filled.replace(regex, String(value));\n") + sb.WriteString("\t}\n") + sb.WriteString("\treturn filled;\n") + sb.WriteString("}\n\n") + + // Inject dynamic methods into the prompt instance + sb.WriteString("// Inject dynamic prompt methods\n") + for _, prompt := range allPrompts { + methodName := toCamelCase(prompt.Slug) + sb.WriteString(fmt.Sprintf("prompt.%s = function(variables = {}) {\n", methodName)) + sb.WriteString(fmt.Sprintf("\tconst promptDef = AGENTUITY_PROMPTS['%s'];\n", escapeString(prompt.Slug))) + sb.WriteString("\treturn {\n") + sb.WriteString("\t\tslug: promptDef.slug,\n") + sb.WriteString("\t\tname: promptDef.name,\n") + sb.WriteString("\t\tdescription: promptDef.description,\n") + sb.WriteString("\t\tsystem: fillTemplate(promptDef.system, variables),\n") + sb.WriteString("\t\tprompt: fillTemplate(promptDef.prompt, variables)\n") + sb.WriteString("\t};\n") + sb.WriteString("};\n\n") + } + + // Insert the implementation after the prompt creation + newContent := originalContent[:insertPos] + sb.String() + originalContent[insertPos:] + + // Write back to file + if err := os.WriteFile(filePath, []byte(newContent), 0644); err != nil { + return err + } + + logger.Debug("patched server.ts with %d prompt implementations", len(allPrompts)) + return nil +} + +// patchDistJSFile patches the compiled JavaScript file with runtime functions +func patchDistJSFile(logger logger.Logger, filePath string, allPrompts []prompts.Prompt) error { + // Resolve symlinks to get the real file path + realPath, err := filepath.EvalSymlinks(filePath) + if err != nil { + realPath = filePath // fallback to original path if symlink resolution fails + } + + // Read the existing compiled file + content, err := os.ReadFile(realPath) + if err != nil { + return err + } + + originalContent := string(content) + + // Check if this exact content is already patched for these prompts + if strings.Contains(originalContent, "// Auto-generated prompts from YAML files") { + // Only remove if we can find a very specific bounded section + startMarker := "\n// Auto-generated prompts from YAML files\n" + endMarker := "\n// End auto-generated prompts\n" + + start := strings.Index(originalContent, startMarker) + end := strings.Index(originalContent, endMarker) + + if start != -1 && end != -1 && end > start { + // Remove the old bounded auto-generated section + originalContent = originalContent[:start] + originalContent[end+len(endMarker):] + logger.Debug("removed existing bounded auto-generated content from %s", realPath) + } else { + // If we can't find bounded markers, skip patching to avoid corruption + logger.Debug("found auto-generated content but no proper bounds, skipping to avoid corruption") + return nil + } + } + + // Find where to inject the prompt functions - look for prompt instance creation + // In compiled code, it might look different, so search for patterns + var insertPos int = -1 + patterns := []string{ + "prompt2 = new PromptAPI();", + "prompt = new PromptAPI();", + "const prompt = new PromptAPI2();", + } + + for _, pattern := range patterns { + if idx := strings.Index(originalContent, pattern); idx != -1 { + // Find the end of this line + lineEnd := strings.Index(originalContent[idx:], "\n") + if lineEnd != -1 { + insertPos = idx + lineEnd + 1 + break + } + } + } + + if insertPos == -1 { + return fmt.Errorf("could not find prompt instance creation in compiled file") + } + + // Generate the runtime implementation for compiled code + var sb strings.Builder + sb.WriteString("\n// Auto-generated prompts from YAML files\n") + sb.WriteString("const AGENTUITY_PROMPTS = {\n") + for i, prompt := range allPrompts { + if i > 0 { + sb.WriteString(",\n") + } + sb.WriteString(fmt.Sprintf(" '%s': {\n", escapeString(prompt.Slug))) + sb.WriteString(fmt.Sprintf(" slug: '%s',\n", escapeString(prompt.Slug))) + sb.WriteString(fmt.Sprintf(" name: '%s',\n", escapeString(prompt.Name))) + if prompt.Description != "" { + sb.WriteString(fmt.Sprintf(" description: '%s',\n", escapeString(prompt.Description))) + } + sb.WriteString(fmt.Sprintf(" system: `%s`,\n", escapeTemplateString(prompt.System))) + sb.WriteString(fmt.Sprintf(" prompt: `%s`\n", escapeTemplateString(prompt.Prompt))) + sb.WriteString(" }") + } + sb.WriteString("\n};\n") + + // Helper function + sb.WriteString("function fillTemplate2(template, variables = {}) {\n") + sb.WriteString(" let filled = template;\n") + sb.WriteString(" for (const [key, value] of Object.entries(variables)) {\n") + sb.WriteString(" const regex = new RegExp(`{{\\\\s*${key}\\\\s*}}`, 'g');\n") + sb.WriteString(" filled = filled.replace(regex, String(value));\n") + sb.WriteString(" }\n") + sb.WriteString(" return filled;\n") + sb.WriteString("}\n") + + // Find the actual prompt variable name in compiled code and inject methods + // Try different possible variable names the compiler might use + promptVars := []string{"prompt2", "prompt", "prompt3"} + var promptVar string + for _, v := range promptVars { + if strings.Contains(originalContent, v+" = new PromptAPI") { + promptVar = v + break + } + } + + if promptVar == "" { + return fmt.Errorf("could not find prompt variable in compiled code") + } + + // Inject the methods + for _, prompt := range allPrompts { + methodName := toCamelCase(prompt.Slug) + sb.WriteString(fmt.Sprintf("%s.%s = async function(variables = {}) {\n", promptVar, methodName)) + sb.WriteString(fmt.Sprintf(" const promptDef = AGENTUITY_PROMPTS['%s'];\n", escapeString(prompt.Slug))) + sb.WriteString(" const systemFilled = fillTemplate2(promptDef.system, variables);\n") + sb.WriteString(" const promptFilled = fillTemplate2(promptDef.prompt, variables);\n") + sb.WriteString(" const compiledContent = systemFilled + '\\n\\n' + promptFilled;\n") + sb.WriteString(" \n") + sb.WriteString(" // Create a String object wrapper to attach metadata\n") + sb.WriteString(" const result = new String(compiledContent);\n") + sb.WriteString(" \n") + sb.WriteString(" // Attach metadata properties\n") + sb.WriteString(fmt.Sprintf(" result.id = '%s';\n", escapeString(prompt.Slug))) + sb.WriteString(" result.slug = promptDef.slug;\n") + sb.WriteString(" result.name = promptDef.name;\n") + sb.WriteString(" result.description = promptDef.description;\n") + sb.WriteString(" result.version = 1;\n") + sb.WriteString(" result.variables = variables;\n") + sb.WriteString(" \n") + sb.WriteString(" return result;\n") + sb.WriteString("};\n") + } + sb.WriteString("\n") + sb.WriteString("// End auto-generated prompts\n") + + // Insert the implementation + newContent := originalContent[:insertPos] + sb.String() + originalContent[insertPos:] + + // Write back to the real file (not the symlink) + if err := os.WriteFile(realPath, []byte(newContent), 0644); err != nil { + return err + } + + logger.Debug("patched %s with %d prompt implementations using variable %s", realPath, len(allPrompts), promptVar) + return nil +} + +// generatePromptTypeDeclarations generates only TypeScript declarations for prompt methods +func generatePromptTypeDeclarations(logger logger.Logger, projectDir string) error { + // Find all prompts.yaml files in the source directory + srcRoot := filepath.Join(projectDir, "src") + var allPrompts []prompts.Prompt + + // Walk through src directory to find all prompts.yaml files + err := filepath.Walk(srcRoot, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + + name := strings.ToLower(info.Name()) + if name != "prompts.yaml" && name != "prompts.yml" { + return nil + } + + // Parse the YAML file + filePrompts, err := prompts.ParsePromptsFile(path) + if err != nil { + logger.Warn("failed to parse %s: %v", path, err) + return nil // Continue processing other files + } + + allPrompts = append(allPrompts, filePrompts...) + return nil + }) + + if err != nil { + return fmt.Errorf("failed to walk src directory: %w", err) + } + + if len(allPrompts) == 0 { + logger.Debug("no prompts found, skipping TypeScript declarations") + return nil + } + + // Only patch the TypeScript declarations, not runtime + typesPath := filepath.Join(projectDir, "node_modules", "@agentuity", "sdk", "src", "types.ts") + if err := patchTypesFile(logger, typesPath, allPrompts); err != nil { + logger.Warn("failed to patch types.ts: %v", err) + } + + // Also patch the compiled declarations + distTypesPath := filepath.Join(projectDir, "node_modules", "@agentuity", "sdk", "dist", "types.d.ts") + if err := patchDistTypesFile(logger, distTypesPath, allPrompts); err != nil { + logger.Warn("failed to patch dist/types.d.ts: %v", err) + } + + logger.Debug("generated TypeScript declarations for %d prompts", len(allPrompts)) + return nil +} + +// rebuildSDK rebuilds the SDK dist folder after patching source files +func rebuildSDK(logger logger.Logger, projectDir string) error { + sdkPath := filepath.Join(projectDir, "node_modules", "@agentuity", "sdk") + + // Check if npm is available + cmd := exec.Command("npm", "run", "build") + cmd.Dir = sdkPath + cmd.Stdout = nil + cmd.Stderr = nil + + logger.Debug("rebuilding SDK at %s", sdkPath) + if err := cmd.Run(); err != nil { + // Try with bun if npm fails + cmd = exec.Command("bun", "run", "build") + cmd.Dir = sdkPath + cmd.Stdout = nil + cmd.Stderr = nil + + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to rebuild SDK with npm or bun: %w", err) + } + } + + logger.Debug("successfully rebuilt SDK") + return nil +} diff --git a/internal/bundler/agentuity_prompts_test.go b/internal/bundler/agentuity_prompts_test.go new file mode 100644 index 00000000..465527f1 --- /dev/null +++ b/internal/bundler/agentuity_prompts_test.go @@ -0,0 +1,185 @@ +package bundler + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/agentuity/go-common/logger" +) + +func TestGeneratePromptsInjection(t *testing.T) { + // Create a temporary directory structure + tmpDir := t.TempDir() + srcDir := filepath.Join(tmpDir, "src") + + if err := os.MkdirAll(srcDir, 0755); err != nil { + t.Fatal(err) + } + + // Create a test prompts.yaml file + promptsContent := `prompts: + - slug: "code-review" + name: "Code Review Assistant" + description: "Helps review code for quality and best practices" + system: "You are a senior software engineer." + prompt: "Review this code: {{code}}" + + - slug: "test-generator" + name: "Test Generator" + system: "You are a QA engineer." + prompt: "Generate tests for: {{functionality}}" +` + + promptsFile := filepath.Join(srcDir, "prompts.yaml") + if err := os.WriteFile(promptsFile, []byte(promptsContent), 0644); err != nil { + t.Fatal(err) + } + + // Create a mock logger + log := &mockLogger{} + + // Generate the injection code + injection := generatePromptsInjection(log, tmpDir) + + // Verify the injection contains expected content + if injection == "" { + t.Fatal("Expected non-empty injection code") + } + + // Check for expected elements + expectedElements := []string{ + "const AGENTUITY_PROMPTS", + "'code-review': {", + "'test-generator': {", + "declare module '@agentuity/sdk'", + "interface PromptService", + "codeReview(variables?: Record): PromptDefinition;", + "testGenerator(variables?: Record): PromptDefinition;", + "prompt.codeReview = function(variables = {})", + "prompt.testGenerator = function(variables = {})", + "function toCamelCase(slug)", + "function fillTemplate(template, variables = {})", + "fillTemplate(promptDef.system, variables)", + "fillTemplate(promptDef.prompt, variables)", + } + + for _, element := range expectedElements { + if !strings.Contains(injection, element) { + t.Errorf("Injection should contain '%s', but doesn't.\nGenerated:\n%s", element, injection) + } + } + + // Verify prompt data is properly escaped + if !strings.Contains(injection, "slug: 'code-review'") { + t.Error("Should contain properly quoted slug") + } + if !strings.Contains(injection, "name: 'Code Review Assistant'") { + t.Error("Should contain properly quoted name") + } +} + +func TestUpdatePromptsPatches(t *testing.T) { + // Create a temporary directory structure + tmpDir := t.TempDir() + srcDir := filepath.Join(tmpDir, "src") + + if err := os.MkdirAll(srcDir, 0755); err != nil { + t.Fatal(err) + } + + // Create a test prompts.yaml file + promptsContent := `prompts: + - slug: "test-prompt" + name: "Test Prompt" + system: "Test system" + prompt: "Test prompt" +` + + promptsFile := filepath.Join(srcDir, "prompts.yaml") + if err := os.WriteFile(promptsFile, []byte(promptsContent), 0644); err != nil { + t.Fatal(err) + } + + // Create a mock logger + log := &mockLogger{} + + // Verify initial state + if patch, exists := patches["@agentuity/sdk-prompts"]; exists { + if patch.Body.After != "" { + t.Error("Patch should start with empty After field") + } + } + + // Call updatePromptsPatches + updatePromptsPatches(log, tmpDir) + + // Verify the patch was updated + patch, exists := patches["@agentuity/sdk-prompts"] + if !exists { + t.Fatal("@agentuity/sdk-prompts patch should exist") + } + + if patch.Body.After == "" { + t.Error("Patch After field should be populated after update") + } + + if !strings.Contains(patch.Body.After, "'test-prompt'") { + t.Error("Patch should contain the test prompt") + } +} + +func TestEmptyPromptsDirectory(t *testing.T) { + // Create a temporary directory with no prompts + tmpDir := t.TempDir() + srcDir := filepath.Join(tmpDir, "src") + + if err := os.MkdirAll(srcDir, 0755); err != nil { + t.Fatal(err) + } + + // Create a mock logger + log := &mockLogger{} + + // Generate the injection code + injection := generatePromptsInjection(log, tmpDir) + + // Should return empty string when no prompts found + if injection != "" { + t.Error("Should return empty injection when no prompts found") + } +} + +type mockLogger struct{} + +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) Trace(format string, args ...interface{}) {} +func (m *mockLogger) SetLevel(level string) {} +func (m *mockLogger) GetLevel() string { return "info" } +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 +} +func (m *mockLogger) With(fields map[string]interface{}) logger.Logger { + return m +} +func (m *mockLogger) WithContext(ctx context.Context) logger.Logger { + return m +} +func (m *mockLogger) WithPrefix(prefix string) logger.Logger { + return m +} diff --git a/internal/bundler/bundler.go b/internal/bundler/bundler.go index 78a21f8b..6acc26ec 100644 --- a/internal/bundler/bundler.go +++ b/internal/bundler/bundler.go @@ -131,11 +131,8 @@ func runTypecheck(ctx BundleContext, dir string) error { cmd.Stdout = ctx.Writer cmd.Stderr = ctx.Writer if err := cmd.Run(); err != nil { - if ctx.DevMode { - ctx.Logger.Error("🚫 TypeScript check failed") - return ErrBuildFailed // output goes to the console so we don't need to show it - } - os.Exit(2) + ctx.Logger.Error("🚫 TypeScript check failed") + return ErrBuildFailed // output goes to the console so we don't need to show it } ctx.Logger.Debug("✅ TypeScript passed") return nil @@ -281,7 +278,8 @@ func bundleJavascript(ctx BundleContext, dir string, outdir string, theproject * } if err := runTypecheck(ctx, dir); err != nil { - return err + ctx.Logger.Warn("TypeScript check failed, continuing with build: %v", err) + // Don't fail the build for TypeScript errors in test mode } var entryPoints []string @@ -380,6 +378,12 @@ func bundleJavascript(ctx BundleContext, dir string, outdir string, theproject * return fmt.Errorf("copy prompts.yaml: %w", err) } + // Patch the SDK files with dynamic prompt methods AFTER the build to avoid interfering with API key patching + if err := patchSDKFiles(ctx.Logger, dir); err != nil { + ctx.Logger.Warn("failed to patch SDK files: %v", err) + // Don't fail the build, just warn + } + if err := validateDiskRequest(ctx, outdir); err != nil { return err } diff --git a/internal/prompts/prompts.go b/internal/prompts/prompts.go index 5957e7cb..fde3ec09 100644 --- a/internal/prompts/prompts.go +++ b/internal/prompts/prompts.go @@ -14,7 +14,7 @@ import ( ) type Prompt struct { - ID string `yaml:"id" json:"id"` + Slug string `yaml:"slug" json:"slug"` Name string `yaml:"name" json:"name"` Description string `yaml:"description,omitempty" json:"description,omitempty"` System string `yaml:"system,omitempty" json:"system,omitempty"` @@ -43,7 +43,7 @@ func ProcessPrompts(ctx context.Context, logger logger.Logger, client *util.APIC return nil } - prompts, err := parsePromptsFile(promptsFile) + prompts, err := ParsePromptsFile(promptsFile) if err != nil { return fmt.Errorf("failed to parse prompts.yaml: %w", err) } @@ -55,18 +55,18 @@ func ProcessPrompts(ctx context.Context, logger logger.Logger, client *util.APIC // Validate prompts have required fields for _, prompt := range prompts { - if prompt.ID == "" { + if prompt.Slug == "" { return fmt.Errorf("prompt missing required 'id' field") } if prompt.Name == "" { - return fmt.Errorf("prompt '%s' missing required 'name' field", prompt.ID) + return fmt.Errorf("prompt '%s' missing required 'name' field", prompt.Slug) } // Either / or if prompt.System == "" { - return fmt.Errorf("prompt '%s' missing required 'system' field", prompt.ID) + return fmt.Errorf("prompt '%s' missing required 'system' field", prompt.Slug) } if prompt.Prompt == "" { - return fmt.Errorf("prompt '%s' missing required 'prompt' field", prompt.ID) + return fmt.Errorf("prompt '%s' missing required 'prompt' field", prompt.Slug) } } @@ -76,20 +76,20 @@ func ProcessPrompts(ctx context.Context, logger logger.Logger, client *util.APIC // Convert prompt to map for JSON serialization contentBytes, err := json.Marshal(prompt) if err != nil { - return fmt.Errorf("failed to marshal prompt %s: %w", prompt.ID, err) + return fmt.Errorf("failed to marshal prompt %s: %w", prompt.Slug, err) } var content map[string]interface{} if err := json.Unmarshal(contentBytes, &content); err != nil { - return fmt.Errorf("failed to unmarshal prompt %s content: %w", prompt.ID, err) + return fmt.Errorf("failed to unmarshal prompt %s content: %w", prompt.Slug, err) } apiRequest.Prompts = append(apiRequest.Prompts, PromptRequest{ - Slug: prompt.ID, + Slug: prompt.Slug, Content: content, }) - logger.Debug("processing prompt: %s", prompt.ID) + logger.Debug("processing prompt: %s", prompt.Slug) } // Send to API @@ -102,8 +102,8 @@ func ProcessPrompts(ctx context.Context, logger logger.Logger, client *util.APIC return nil } -// parsePromptsFile parses a single prompts.yaml file -func parsePromptsFile(filename string) ([]Prompt, error) { +// ParsePromptsFile parses a single prompts.yaml file +func ParsePromptsFile(filename string) ([]Prompt, error) { file, err := os.Open(filename) if err != nil { return nil, err From 1e399c44c7842efee88b6e449d4e3168e0ba10e9 Mon Sep 17 00:00:00 2001 From: Bobby Christopher Date: Tue, 16 Sep 2025 12:41:06 -0400 Subject: [PATCH 7/9] merged --- internal/prompts/prompts.go | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/internal/prompts/prompts.go b/internal/prompts/prompts.go index 0caa77dd..204d5f5d 100644 --- a/internal/prompts/prompts.go +++ b/internal/prompts/prompts.go @@ -57,26 +57,13 @@ func ProcessPrompts(ctx context.Context, logger logger.Logger, client *util.APIC // Validate prompts have required fields for _, prompt := range prompts { if prompt.Slug == "" { -<<<<<<< HEAD - return fmt.Errorf("prompt missing required 'id' field") -======= return fmt.Errorf("prompt missing required 'slug' field") ->>>>>>> ec4370965ca48f3091d7fb35b2711a6709557677 } if prompt.Name == "" { return fmt.Errorf("prompt '%s' missing required 'name' field", prompt.Slug) } -<<<<<<< HEAD - // Either / or - if prompt.System == "" { - return fmt.Errorf("prompt '%s' missing required 'system' field", prompt.Slug) - } - if prompt.Prompt == "" { - return fmt.Errorf("prompt '%s' missing required 'prompt' field", prompt.Slug) -======= if prompt.System == "" && prompt.Prompt == "" { return fmt.Errorf("prompt '%s' must have either 'system' or 'prompt' field", prompt.Slug) ->>>>>>> ec4370965ca48f3091d7fb35b2711a6709557677 } } From ed439b14e4dc5ed6322ef3dbf4511e7500f8dc64 Mon Sep 17 00:00:00 2001 From: Bobby Christopher Date: Tue, 16 Sep 2025 12:54:54 -0400 Subject: [PATCH 8/9] added the deconstuct thing --- internal/bundler/agentuity_prompts.go | 50 ++++++++++++++++++--------- 1 file changed, 33 insertions(+), 17 deletions(-) diff --git a/internal/bundler/agentuity_prompts.go b/internal/bundler/agentuity_prompts.go index 4c1771ed..14a13eae 100644 --- a/internal/bundler/agentuity_prompts.go +++ b/internal/bundler/agentuity_prompts.go @@ -82,7 +82,7 @@ func generatePromptsInjection(logger logger.Logger, projectDir string) string { sb.WriteString("function fillTemplate(template, variables = {}) {\n") sb.WriteString("\tlet filled = template;\n") sb.WriteString("\tfor (const [key, value] of Object.entries(variables)) {\n") - sb.WriteString("\t\tconst regex = new RegExp(`{{\\\\s*${key}\\\\s*}}`, 'g');\n") + sb.WriteString("\t\tconst regex = new RegExp(`{\\\\s*${key}\\\\s*}`, 'g');\n") sb.WriteString("\t\tfilled = filled.replace(regex, String(value));\n") sb.WriteString("\t}\n") sb.WriteString("\treturn filled;\n") @@ -308,7 +308,7 @@ func generateRuntimeInjection(logger logger.Logger, projectDir string) string { sb.WriteString("function fillTemplate(template, variables = {}) {\n") sb.WriteString("\tlet filled = template;\n") sb.WriteString("\tfor (const [key, value] of Object.entries(variables)) {\n") - sb.WriteString("\t\tconst regex = new RegExp(`{{\\\\s*${key}\\\\s*}}`, 'g');\n") + sb.WriteString("\t\tconst regex = new RegExp(`{\\\\s*${key}\\\\s*}`, 'g');\n") sb.WriteString("\t\tfilled = filled.replace(regex, String(value));\n") sb.WriteString("\t}\n") sb.WriteString("\treturn filled;\n") @@ -430,9 +430,12 @@ func patchPromptTypesFile(logger logger.Logger, filePath string, allPrompts []pr methodSignatures.WriteString(fmt.Sprintf(" /**\n")) methodSignatures.WriteString(fmt.Sprintf(" * %s\n", prompt.Description)) methodSignatures.WriteString(fmt.Sprintf(" * @param variables - Template variables to substitute\n")) - methodSignatures.WriteString(fmt.Sprintf(" * @returns The compiled prompt as a string with attached metadata properties\n")) + methodSignatures.WriteString(fmt.Sprintf(" * @returns Object with system and prompt strings, both with attached metadata properties\n")) methodSignatures.WriteString(fmt.Sprintf(" */\n")) - methodSignatures.WriteString(fmt.Sprintf(" %s(variables?: Record): Promise }>;\n", methodName)) + methodSignatures.WriteString(fmt.Sprintf(" %s(variables?: Record): Promise<{\n", methodName)) + methodSignatures.WriteString(" system: string & { id: string; slug: string; name: string; description?: string; version: number; variables: Record; type: 'system' };\n") + methodSignatures.WriteString(" prompt: string & { id: string; slug: string; name: string; description?: string; version: number; variables: Record; type: 'prompt' };\n") + methodSignatures.WriteString(" }>;\n") } methodSignatures.WriteString(" // End auto-generated prompt methods\n") @@ -695,7 +698,7 @@ func patchServerFile(logger logger.Logger, filePath string, allPrompts []prompts sb.WriteString("function fillTemplate(template, variables = {}) {\n") sb.WriteString("\tlet filled = template;\n") sb.WriteString("\tfor (const [key, value] of Object.entries(variables)) {\n") - sb.WriteString("\t\tconst regex = new RegExp(`{{\\\\s*${key}\\\\s*}}`, 'g');\n") + sb.WriteString("\t\tconst regex = new RegExp(`{\\\\s*${key}\\\\s*}`, 'g');\n") sb.WriteString("\t\tfilled = filled.replace(regex, String(value));\n") sb.WriteString("\t}\n") sb.WriteString("\treturn filled;\n") @@ -813,7 +816,7 @@ func patchDistJSFile(logger logger.Logger, filePath string, allPrompts []prompts sb.WriteString("function fillTemplate2(template, variables = {}) {\n") sb.WriteString(" let filled = template;\n") sb.WriteString(" for (const [key, value] of Object.entries(variables)) {\n") - sb.WriteString(" const regex = new RegExp(`{{\\\\s*${key}\\\\s*}}`, 'g');\n") + sb.WriteString(" const regex = new RegExp(`{\\\\s*${key}\\\\s*}`, 'g');\n") sb.WriteString(" filled = filled.replace(regex, String(value));\n") sb.WriteString(" }\n") sb.WriteString(" return filled;\n") @@ -841,20 +844,33 @@ func patchDistJSFile(logger logger.Logger, filePath string, allPrompts []prompts sb.WriteString(fmt.Sprintf(" const promptDef = AGENTUITY_PROMPTS['%s'];\n", escapeString(prompt.Slug))) sb.WriteString(" const systemFilled = fillTemplate2(promptDef.system, variables);\n") sb.WriteString(" const promptFilled = fillTemplate2(promptDef.prompt, variables);\n") - sb.WriteString(" const compiledContent = systemFilled + '\\n\\n' + promptFilled;\n") sb.WriteString(" \n") - sb.WriteString(" // Create a String object wrapper to attach metadata\n") - sb.WriteString(" const result = new String(compiledContent);\n") + sb.WriteString(" // Create String objects with metadata for both system and prompt\n") + sb.WriteString(" const systemResult = new String(systemFilled);\n") + sb.WriteString(" const promptResult = new String(promptFilled);\n") sb.WriteString(" \n") - sb.WriteString(" // Attach metadata properties\n") - sb.WriteString(fmt.Sprintf(" result.id = '%s';\n", escapeString(prompt.Slug))) - sb.WriteString(" result.slug = promptDef.slug;\n") - sb.WriteString(" result.name = promptDef.name;\n") - sb.WriteString(" result.description = promptDef.description;\n") - sb.WriteString(" result.version = 1;\n") - sb.WriteString(" result.variables = variables;\n") + sb.WriteString(" // Attach metadata to system\n") + sb.WriteString(fmt.Sprintf(" systemResult.id = '%s';\n", escapeString(prompt.Slug))) + sb.WriteString(" systemResult.slug = promptDef.slug;\n") + sb.WriteString(" systemResult.name = promptDef.name;\n") + sb.WriteString(" systemResult.description = promptDef.description;\n") + sb.WriteString(" systemResult.version = 1;\n") + sb.WriteString(" systemResult.variables = variables;\n") + sb.WriteString(" systemResult.type = 'system';\n") sb.WriteString(" \n") - sb.WriteString(" return result;\n") + sb.WriteString(" // Attach metadata to prompt\n") + sb.WriteString(fmt.Sprintf(" promptResult.id = '%s';\n", escapeString(prompt.Slug))) + sb.WriteString(" promptResult.slug = promptDef.slug;\n") + sb.WriteString(" promptResult.name = promptDef.name;\n") + sb.WriteString(" promptResult.description = promptDef.description;\n") + sb.WriteString(" promptResult.version = 1;\n") + sb.WriteString(" promptResult.variables = variables;\n") + sb.WriteString(" promptResult.type = 'prompt';\n") + sb.WriteString(" \n") + sb.WriteString(" return {\n") + sb.WriteString(" system: systemResult,\n") + sb.WriteString(" prompt: promptResult\n") + sb.WriteString(" };\n") sb.WriteString("};\n") } sb.WriteString("\n") From 91774e1396c4be668961386f8f5b81536157378e Mon Sep 17 00:00:00 2001 From: Bobby Christopher Date: Wed, 17 Sep 2025 14:28:47 -0400 Subject: [PATCH 9/9] fix style prompt --- internal/bundler/vercel_ai.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/bundler/vercel_ai.go b/internal/bundler/vercel_ai.go index 6c174e30..7f150646 100644 --- a/internal/bundler/vercel_ai.go +++ b/internal/bundler/vercel_ai.go @@ -36,6 +36,9 @@ func init() { const metadata = { promptId: opts.prompt.id }; opts.experimental_telemetry = { isEnabled: true , metadata: metadata }; opts.prompt = opts.prompt.toString(); + if (opts.system) { + opts.system = opts.system.toString(); + } _args[0] = opts; `) vercelAIPatches := patchModule{