summaryrefslogtreecommitdiff
path: root/internal/promptstore
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-11 22:14:42 +0200
committerPaul Buetow <paul@buetow.org>2026-02-11 22:14:42 +0200
commitd3810ca268f8db2867ae838d0655fb7a56e67252 (patch)
tree23c18a31f35f1d94249e50d3e66a66e4f9ec7853 /internal/promptstore
parenta82d0b061a02fd395de293353386d0b16cbe6b18 (diff)
refactor: compile built-in prompts into binary instead of external files
This change moves built-in meta-prompts (save_prompt, update_prompt) from external JSONL files into compiled Go code, making them always available and version-controlled with the binary. Changes: - Add default_prompts.go with built-in meta-prompt definitions - Update store to load built-ins from code, not files - Add protection: built-ins cannot be updated/deleted - Handle name conflicts: built-ins take precedence with warnings - Update docs to reflect new architecture (no default.jsonl needed) - Add comprehensive tests for built-in protection - Add hexai-mcp-server binary to .gitignore Benefits: - Built-ins always in sync with binary version - No setup required (no default.jsonl to manage) - Clear separation between built-in and user prompts - Protection prevents accidental modification of meta-prompts Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Diffstat (limited to 'internal/promptstore')
-rw-r--r--internal/promptstore/default_prompts.go149
-rw-r--r--internal/promptstore/store.go83
-rw-r--r--internal/promptstore/store_test.go257
3 files changed, 478 insertions, 11 deletions
diff --git a/internal/promptstore/default_prompts.go b/internal/promptstore/default_prompts.go
new file mode 100644
index 0000000..ea8c793
--- /dev/null
+++ b/internal/promptstore/default_prompts.go
@@ -0,0 +1,149 @@
+// Summary: Built-in meta-prompts for prompt management.
+package promptstore
+
+import (
+ "time"
+)
+
+// DefaultPrompts returns the built-in meta-prompts for prompt management.
+// These prompts help users create and update prompts interactively using Claude's
+// access to conversation context.
+func DefaultPrompts() []Prompt {
+ now := time.Now()
+
+ return []Prompt{
+ {
+ Name: "save_prompt",
+ Title: "Save Current Conversation as Prompt",
+ Description: "Interactively create a new prompt template from the current conversation. Claude will analyze the conversation, ask clarifying questions about templating, show a preview, and wait for approval before saving.",
+ Arguments: []PromptArgument{
+ {
+ Name: "prompt_name",
+ Description: "Unique identifier for the new prompt (lowercase, underscores allowed)",
+ Required: true,
+ },
+ {
+ Name: "prompt_title",
+ Description: "Human-readable display name for the new prompt",
+ Required: true,
+ },
+ },
+ Messages: []PromptMessage{
+ {
+ Role: "user",
+ Content: MessageContent{
+ Type: "text",
+ Text: `I want to create a new prompt template named '{{prompt_name}}' with title '{{prompt_title}}'.
+
+Please help me by:
+1) Analyzing our current conversation to understand what should be templated
+2) Asking me clarifying questions about:
+ - What parts should be template arguments vs fixed text
+ - What description would best explain this prompt's purpose
+ - What tags would help categorize it
+ - Whether multi-turn messages are needed
+3) Showing me a complete preview of the prompt structure in a code block
+4) Only after I approve, use the MCP prompts/create method to save it
+
+IMPORTANT FORMATTING RULES for clarifying questions:
+- Use numbered questions: 1), 2), 3)
+- ANY CHOICE MUST BE NUMBERED using combined format: 1a), 1b), 1c), 2a), 2b), etc.
+- NEVER use standalone letters like "a)" - always combine with question number
+- NEVER use dashes (-) or bullets (•) for options
+- Every option must be numbered for easy selection by the user
+
+Examples:
+ 1) Question Category
+ Which do you prefer?
+ 1a) First option
+ 1b) Second option
+ 1c) Third option
+
+ 2) Arguments
+ Should this accept parameters?
+ 2a) No arguments - fixed behavior
+ 2b) Optional file_pattern argument
+ 2c) Multiple optional arguments
+
+ 3) Sub-items
+ Consider these aspects:
+ 3a) First aspect to consider
+ 3b) Second aspect to consider
+ 3c) Third aspect to consider
+
+ 4) Multiple sub-questions
+ 4a) Sub-question one?
+ Answer options here
+ 4b) Sub-question two?
+ Answer options here
+
+Start by examining our conversation and asking your clarifying questions using this format.`,
+ },
+ },
+ },
+ Tags: []string{"meta", "prompt-management", "interactive"},
+ Created: now,
+ Updated: now,
+ },
+ {
+ Name: "update_prompt",
+ Title: "Update Existing Prompt",
+ Description: "Interactively modify an existing prompt. Claude will fetch the current version, ask what changes you want, show a preview with changes highlighted, and wait for approval before updating.",
+ Arguments: []PromptArgument{
+ {
+ Name: "prompt_name",
+ Description: "Name of the existing prompt to update",
+ Required: true,
+ },
+ },
+ Messages: []PromptMessage{
+ {
+ Role: "user",
+ Content: MessageContent{
+ Type: "text",
+ Text: `I want to update the existing prompt '{{prompt_name}}'.
+
+Please help me by:
+1) First, retrieve the current prompt using prompts/get to show me what exists
+2) Ask me what changes I want to make (description, arguments, messages, tags)
+3) If I reference content from our current conversation, help extract and template it
+4) Show me a complete preview of the updated prompt with changes highlighted
+5) Only after I approve, use the MCP prompts/update method to save the changes
+
+IMPORTANT FORMATTING RULES for clarifying questions:
+- Use numbered questions: 1), 2), 3)
+- ANY CHOICE MUST BE NUMBERED using combined format: 1a), 1b), 1c), 2a), 2b), etc.
+- NEVER use standalone letters like "a)" - always combine with question number
+- NEVER use dashes (-) or bullets (•) for options
+- Every option must be numbered for easy selection by the user
+
+Examples:
+ 1) Question Category
+ Which do you prefer?
+ 1a) First option
+ 1b) Second option
+ 1c) Third option
+
+ 2) Changes to Make
+ What aspects should be updated?
+ 2a) Description only
+ 2b) Arguments only
+ 2c) Both description and arguments
+ 2d) Complete rewrite
+
+ 3) Multiple aspects
+ Consider:
+ 3a) First aspect to evaluate
+ 3b) Second aspect to evaluate
+ 3c) Third aspect to evaluate
+
+Start by fetching and showing me the current prompt, then ask clarifying questions using this format.`,
+ },
+ },
+ },
+ Tags: []string{"meta", "prompt-management", "interactive"},
+ Created: now,
+ Updated: now,
+ },
+ }
+}
diff --git a/internal/promptstore/store.go b/internal/promptstore/store.go
index e789dda..b4b9586 100644
--- a/internal/promptstore/store.go
+++ b/internal/promptstore/store.go
@@ -37,7 +37,7 @@ type PromptStore interface {
}
// JSONLStore is a file-based prompt store using JSONL format.
-// Stores prompts in multiple JSONL files (default.jsonl for built-ins, user.jsonl for custom).
+// Built-in prompts are loaded from code, user prompts are stored in user.jsonl.
// Automatically creates backups before any write operation.
type JSONLStore struct {
dataDir string
@@ -76,7 +76,7 @@ func NewJSONLStore(dataDir string) (PromptStore, error) {
}
// List returns prompts with pagination.
-// cursor format: "<file>:<offset>" where file is "default" or "user", offset is line number.
+// Returns both built-in prompts (from code) and user prompts (from user.jsonl).
func (s *JSONLStore) List(cursor string, limit int) ([]Prompt, string, error) {
s.mu.RLock()
defer s.mu.RUnlock()
@@ -150,12 +150,21 @@ func (s *JSONLStore) Create(prompt *Prompt) error {
return fmt.Errorf("backup failed: %w", err)
}
- // Check if prompt already exists (use internal method to avoid deadlock)
- allPrompts, err := s.loadAllPrompts()
+ // Check if prompt already exists in built-ins
+ isBuiltIn, err := s.isBuiltInPrompt(prompt.Name)
if err != nil {
- return fmt.Errorf("load prompts: %w", err)
+ return fmt.Errorf("check built-in prompts: %w", err)
}
- for _, p := range allPrompts {
+ if isBuiltIn {
+ return fmt.Errorf("prompt already exists: %s (choose a different name)", prompt.Name)
+ }
+
+ // Check if prompt already exists in user prompts
+ userPrompts, err := s.loadPromptsFromFile("user.jsonl")
+ if err != nil && !os.IsNotExist(err) {
+ return fmt.Errorf("load user prompts: %w", err)
+ }
+ for _, p := range userPrompts {
if p.Name == prompt.Name {
return fmt.Errorf("prompt already exists: %s", prompt.Name)
}
@@ -184,10 +193,20 @@ func (s *JSONLStore) Create(prompt *Prompt) error {
// Update modifies an existing prompt in user.jsonl.
// Note: This rewrites the entire user.jsonl file.
+// Cannot update built-in prompts (returns error).
func (s *JSONLStore) Update(prompt *Prompt) error {
s.mu.Lock()
defer s.mu.Unlock()
+ // Check if this is a built-in prompt (cannot be updated)
+ isBuiltIn, err := s.isBuiltInPrompt(prompt.Name)
+ if err != nil {
+ return fmt.Errorf("check built-in prompts: %w", err)
+ }
+ if isBuiltIn {
+ return fmt.Errorf("cannot update built-in prompt: %s (create a new prompt with a different name instead)", prompt.Name)
+ }
+
// Backup before write
if err := s.backupUserPrompts(); err != nil {
return fmt.Errorf("backup failed: %w", err)
@@ -218,10 +237,20 @@ func (s *JSONLStore) Update(prompt *Prompt) error {
}
// Delete removes a prompt from user.jsonl.
+// Cannot delete built-in prompts (returns error).
func (s *JSONLStore) Delete(name string) error {
s.mu.Lock()
defer s.mu.Unlock()
+ // Check if this is a built-in prompt (cannot be deleted)
+ isBuiltIn, err := s.isBuiltInPrompt(name)
+ if err != nil {
+ return fmt.Errorf("check built-in prompts: %w", err)
+ }
+ if isBuiltIn {
+ return fmt.Errorf("cannot delete built-in prompt: %s", name)
+ }
+
// Backup before write
if err := s.backupUserPrompts(); err != nil {
return fmt.Errorf("backup failed: %w", err)
@@ -291,14 +320,52 @@ func (s *JSONLStore) hasAllTags(promptTags, searchTags []string) bool {
return true
}
-// loadAllPrompts loads prompts from user.jsonl.
+// loadAllPrompts loads prompts from both built-in code and user.jsonl.
+// Built-in prompts (from code) take precedence in case of name conflicts.
+// Logs a warning to stderr if a user prompt conflicts with a built-in.
func (s *JSONLStore) loadAllPrompts() ([]Prompt, error) {
+ // Load built-in prompts directly from code (no file needed)
+ builtInPrompts := DefaultPrompts()
+
+ // Load user prompts from user.jsonl
userPrompts, err := s.loadPromptsFromFile("user.jsonl")
if err != nil && !os.IsNotExist(err) {
return nil, err
}
- return userPrompts, nil
+ // Create a map of built-in prompt names for conflict detection
+ builtInNames := make(map[string]bool)
+ for _, p := range builtInPrompts {
+ builtInNames[p.Name] = true
+ }
+
+ // Combine prompts, skipping user prompts that conflict with built-ins
+ allPrompts := make([]Prompt, 0, len(builtInPrompts)+len(userPrompts))
+ allPrompts = append(allPrompts, builtInPrompts...)
+
+ for _, p := range userPrompts {
+ if builtInNames[p.Name] {
+ fmt.Fprintf(os.Stderr, "warning: skipping user prompt '%s' - conflicts with built-in\n", p.Name)
+ continue
+ }
+ allPrompts = append(allPrompts, p)
+ }
+
+ return allPrompts, nil
+}
+
+// isBuiltInPrompt checks if a prompt with the given name exists in the built-in prompts.
+// Returns true if the prompt is a built-in (read-only) prompt.
+func (s *JSONLStore) isBuiltInPrompt(name string) (bool, error) {
+ builtIns := DefaultPrompts()
+
+ for _, p := range builtIns {
+ if p.Name == name {
+ return true, nil
+ }
+ }
+
+ return false, nil
}
// loadPromptsFromFile reads prompts from a JSONL file.
diff --git a/internal/promptstore/store_test.go b/internal/promptstore/store_test.go
index 167dcf6..6e74b17 100644
--- a/internal/promptstore/store_test.go
+++ b/internal/promptstore/store_test.go
@@ -3,6 +3,8 @@ package promptstore
import (
"fmt"
+ "os"
+ "path/filepath"
"testing"
"time"
)
@@ -82,9 +84,9 @@ func TestJSONLStore_List(t *testing.T) {
t.Fatalf("List() error = %v", err)
}
- // Should have all prompts
- if len(prompts) != 7 {
- t.Errorf("List() got %d prompts, want 7", len(prompts))
+ // Should have all prompts (7 user + 2 built-ins)
+ if len(prompts) != 9 {
+ t.Errorf("List() got %d prompts, want 9 (7 user + 2 built-ins)", len(prompts))
}
// No cursor for full list
@@ -306,3 +308,252 @@ func TestJSONLStore_SearchByTags(t *testing.T) {
t.Errorf("SearchByTags() got prompt %s, want test1", results[0].Name)
}
}
+
+func TestJSONLStore_LoadAllPrompts_CodeAndFile(t *testing.T) {
+ t.Run("loads from both code (built-ins) and user.jsonl", func(t *testing.T) {
+ tmpDir := t.TempDir()
+ store, err := NewJSONLStore(tmpDir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ // Create a user prompt
+ userPrompt := &Prompt{
+ Name: "user_test",
+ Title: "User Test",
+ Messages: []PromptMessage{{Role: "user", Content: MessageContent{Type: "text", Text: "User test"}}},
+ Created: time.Now(),
+ Updated: time.Now(),
+ }
+ if err := store.Create(userPrompt); err != nil {
+ t.Fatalf("Create() error = %v", err)
+ }
+
+ // List all prompts (should include both built-ins from code and user prompt)
+ prompts, _, err := store.List("", 100)
+ if err != nil {
+ t.Fatalf("List() error = %v", err)
+ }
+
+ // Check we have at least the built-ins (save_prompt, update_prompt) + user prompt
+ if len(prompts) < 3 {
+ t.Errorf("List() got %d prompts, want at least 3 (2 built-ins + 1 user)", len(prompts))
+ }
+
+ // Verify built-in prompts are present
+ hasBuiltIn := false
+ hasUser := false
+ for _, p := range prompts {
+ if p.Name == "save_prompt" || p.Name == "update_prompt" {
+ hasBuiltIn = true
+ }
+ if p.Name == "user_test" {
+ hasUser = true
+ }
+ }
+
+ if !hasBuiltIn {
+ t.Error("List() missing built-in prompts (save_prompt or update_prompt)")
+ }
+ if !hasUser {
+ t.Error("List() missing user prompt (user_test)")
+ }
+ })
+
+ t.Run("built-ins take precedence over user prompts with same name", func(t *testing.T) {
+ tmpDir := t.TempDir()
+ store, err := NewJSONLStore(tmpDir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ // Get the built-in save_prompt (from code)
+ builtIn, err := store.Get("save_prompt")
+ if err != nil {
+ t.Fatalf("Get(save_prompt) error = %v", err)
+ }
+ originalTitle := builtIn.Title
+
+ // Manually add a conflicting prompt to user.jsonl (bypass protection)
+ // This simulates a conflict scenario
+ jStore := store.(*JSONLStore)
+ conflictPrompt := &Prompt{
+ Name: "save_prompt",
+ Title: "Conflicting Title",
+ Messages: []PromptMessage{{Role: "user", Content: MessageContent{Type: "text", Text: "Conflict"}}},
+ Created: time.Now(),
+ Updated: time.Now(),
+ }
+ // Write directly to user.jsonl without using Create (which has protection)
+ userPrompts := []Prompt{*conflictPrompt}
+ if err := jStore.writePromptsToFile("user.jsonl", userPrompts); err != nil {
+ t.Fatalf("writePromptsToFile() error = %v", err)
+ }
+
+ // Get save_prompt again - should return built-in from code, not user version
+ result, err := store.Get("save_prompt")
+ if err != nil {
+ t.Fatalf("Get(save_prompt) after conflict error = %v", err)
+ }
+
+ if result.Title != originalTitle {
+ t.Errorf("Get(save_prompt) title = %v, want %v (built-in should take precedence)", result.Title, originalTitle)
+ }
+ })
+}
+
+func TestJSONLStore_BuiltInProtection_Update(t *testing.T) {
+ tmpDir := t.TempDir()
+ store, err := NewJSONLStore(tmpDir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ // Get the built-in save_prompt
+ builtIn, err := store.Get("save_prompt")
+ if err != nil {
+ t.Fatalf("Get(save_prompt) error = %v", err)
+ }
+
+ // Try to update it
+ builtIn.Title = "Modified Title"
+ err = store.Update(builtIn)
+
+ // Should fail with clear error message
+ if err == nil {
+ t.Fatal("Update() on built-in prompt should fail")
+ }
+
+ expectedMsg := "cannot update built-in prompt: save_prompt"
+ if !contains(err.Error(), expectedMsg) {
+ t.Errorf("Update() error = %v, want error containing %q", err, expectedMsg)
+ }
+}
+
+func TestJSONLStore_BuiltInProtection_Delete(t *testing.T) {
+ tmpDir := t.TempDir()
+ store, err := NewJSONLStore(tmpDir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ // Verify save_prompt exists
+ _, err = store.Get("save_prompt")
+ if err != nil {
+ t.Fatalf("Get(save_prompt) error = %v (built-in should exist)", err)
+ }
+
+ // Try to delete it
+ err = store.Delete("save_prompt")
+
+ // Should fail with clear error message
+ if err == nil {
+ t.Fatal("Delete() on built-in prompt should fail")
+ }
+
+ expectedMsg := "cannot delete built-in prompt: save_prompt"
+ if !contains(err.Error(), expectedMsg) {
+ t.Errorf("Delete() error = %v, want error containing %q", err, expectedMsg)
+ }
+
+ // Verify it still exists
+ _, err = store.Get("save_prompt")
+ if err != nil {
+ t.Error("Get(save_prompt) after failed delete should still work")
+ }
+}
+
+func TestJSONLStore_Create_NameConflictWithBuiltIn(t *testing.T) {
+ tmpDir := t.TempDir()
+ store, err := NewJSONLStore(tmpDir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ // Try to create a prompt with the same name as a built-in
+ conflictPrompt := &Prompt{
+ Name: "save_prompt",
+ Title: "My Custom Save",
+ Messages: []PromptMessage{{Role: "user", Content: MessageContent{Type: "text", Text: "Custom"}}},
+ Created: time.Now(),
+ Updated: time.Now(),
+ }
+
+ err = store.Create(conflictPrompt)
+
+ // Should fail with clear error message
+ if err == nil {
+ t.Fatal("Create() with built-in name should fail")
+ }
+
+ expectedMsg := "prompt already exists: save_prompt"
+ if !contains(err.Error(), expectedMsg) {
+ t.Errorf("Create() error = %v, want error containing %q", err, expectedMsg)
+ }
+}
+
+func TestJSONLStore_BuiltInPromptsLoadedFromCode(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ // Create store (built-ins loaded from code, not from file)
+ store, err := NewJSONLStore(tmpDir)
+ if err != nil {
+ t.Fatalf("NewJSONLStore() error = %v", err)
+ }
+
+ // Verify save_prompt exists (loaded from code)
+ savePrompt, err := store.Get("save_prompt")
+ if err != nil {
+ t.Fatalf("Get(save_prompt) error = %v (should be loaded from code)", err)
+ }
+ if savePrompt.Name != "save_prompt" {
+ t.Errorf("Get(save_prompt) name = %v, want save_prompt", savePrompt.Name)
+ }
+
+ // Verify update_prompt exists (loaded from code)
+ updatePrompt, err := store.Get("update_prompt")
+ if err != nil {
+ t.Fatalf("Get(update_prompt) error = %v (should be loaded from code)", err)
+ }
+ if updatePrompt.Name != "update_prompt" {
+ t.Errorf("Get(update_prompt) name = %v, want update_prompt", updatePrompt.Name)
+ }
+
+ // Verify both have correct tags
+ if !containsTag(savePrompt.Tags, "meta") {
+ t.Error("save_prompt should have 'meta' tag")
+ }
+ if !containsTag(updatePrompt.Tags, "meta") {
+ t.Error("update_prompt should have 'meta' tag")
+ }
+
+ // Verify no default.jsonl file was created
+ defaultPath := filepath.Join(tmpDir, "default.jsonl")
+ if _, err := os.Stat(defaultPath); !os.IsNotExist(err) {
+ t.Error("default.jsonl should not be created (built-ins loaded from code)")
+ }
+}
+
+// Helper function to check if a string contains a substring
+func contains(s, substr string) bool {
+ return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && containsSubstring(s, substr))
+}
+
+func containsSubstring(s, substr string) bool {
+ for i := 0; i <= len(s)-len(substr); i++ {
+ if s[i:i+len(substr)] == substr {
+ return true
+ }
+ }
+ return false
+}
+
+// Helper function to check if a slice contains a tag
+func containsTag(tags []string, tag string) bool {
+ for _, t := range tags {
+ if t == tag {
+ return true
+ }
+ }
+ return false
+}