Browse Source

Add natural language support to CLI for timestamps and cron

Make schedule creation much easier with human-friendly input:

One-Time Schedules:
- Relative: 'in 5 minutes', 'in 2 hours', 'in 3 days'
- Tomorrow: 'tomorrow at 9am', 'tomorrow at 14:30'
- Next weekday: 'next monday at 3pm', 'next friday at 10:00'
- ISO 8601 still supported: '2025-11-12T19:30:00Z'

Recurring Schedules:
- Minutes: 'every 5 minutes', 'every 30 minutes'
- Hourly/Daily: 'every hour', 'daily at 9am'
- Weekdays: 'every monday', 'every friday at 3pm', 'every weekday'
- Weekly/Monthly: 'weekly', 'monthly'
- Traditional cron still supported: '*/5 * * * *'

Implementation:
- Added parser package with time_parser.go and cron_parser.go
- Updated CLI commands to parse natural language input
- Added comprehensive examples to help text and README
- Backwards compatible with existing ISO 8601 and cron syntax

Examples:
  letta-schedules onetime create --agent-id xxx --message 'hi' --execute-at 'in 5 minutes'
  letta-schedules recurring create --agent-id xxx --message 'daily' --cron 'every weekday'

👾 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta <noreply@letta.com>
Cameron Pfiffer 5 months ago
parent
commit
c6bb515b40
5 changed files with 446 additions and 4 deletions
  1. 51 0
      cli/README.md
  2. 9 2
      cli/cmd/onetime.go
  3. 9 2
      cli/cmd/recurring.go
  4. 178 0
      cli/internal/parser/cron_parser.go
  5. 199 0
      cli/internal/parser/time_parser.go

+ 51 - 0
cli/README.md

@@ -76,6 +76,57 @@ letta-schedules recurring create \
 letta-schedules recurring list
 ```
 
+## Natural Language Support
+
+The CLI supports natural language input for both time expressions and cron schedules, making it easy to create schedules without memorizing syntax!
+
+### One-Time Schedules (Timestamps)
+
+```bash
+# Relative time
+--execute-at "in 5 minutes"
+--execute-at "in 2 hours"
+--execute-at "in 3 days"
+
+# Tomorrow
+--execute-at "tomorrow at 9am"
+--execute-at "tomorrow at 14:30"
+
+# Next weekday
+--execute-at "next monday at 3pm"
+--execute-at "next friday at 10:00"
+
+# ISO 8601 (still supported)
+--execute-at "2025-11-12T19:30:00Z"
+```
+
+### Recurring Schedules (Cron Expressions)
+
+```bash
+# Minutes
+--cron "every 5 minutes"
+--cron "every 30 minutes"
+
+# Hourly/Daily
+--cron "every hour"
+--cron "daily at 9am"
+--cron "daily at 14:30"
+
+# Weekdays
+--cron "every monday"
+--cron "every friday at 3pm"
+--cron "every weekday"     # Mon-Fri at 9am
+--cron "every weekend"     # Sat-Sun at 9am
+
+# Weekly/Monthly
+--cron "weekly"            # Every Monday at 9am
+--cron "monthly"           # 1st of month at 9am
+
+# Traditional cron (still supported)
+--cron "*/5 * * * *"       # Every 5 minutes
+--cron "0 9 * * 1-5"       # Weekdays at 9am
+```
+
 ## Usage
 
 ### Configuration Commands

+ 9 - 2
cli/cmd/onetime.go

@@ -7,6 +7,7 @@ import (
 	"github.com/fatih/color"
 	"github.com/letta/letta-schedules-cli/internal/client"
 	"github.com/letta/letta-schedules-cli/internal/config"
+	"github.com/letta/letta-schedules-cli/internal/parser"
 	"github.com/olekukonko/tablewriter"
 	"github.com/spf13/cobra"
 )
@@ -30,6 +31,12 @@ var onetimeCreateCmd = &cobra.Command{
 			return fmt.Errorf("agent-id, message, and execute-at are required")
 		}
 
+		// Parse natural language time to ISO 8601
+		parsedTime, err := parser.ParseTime(executeAt)
+		if err != nil {
+			return fmt.Errorf("failed to parse execute-at: %w", err)
+		}
+
 		cfg, err := config.Load()
 		if err != nil {
 			return err
@@ -43,7 +50,7 @@ var onetimeCreateCmd = &cobra.Command{
 			AgentID:   agentID,
 			Message:   message,
 			Role:      role,
-			ExecuteAt: executeAt,
+			ExecuteAt: parsedTime,
 		})
 		if err != nil {
 			return fmt.Errorf("failed to create schedule: %w", err)
@@ -174,7 +181,7 @@ func init() {
 	onetimeCreateCmd.Flags().String("agent-id", "", "Agent ID (required)")
 	onetimeCreateCmd.Flags().String("message", "", "Message to send (required)")
 	onetimeCreateCmd.Flags().String("role", "user", "Message role (default: user)")
-	onetimeCreateCmd.Flags().String("execute-at", "", "ISO 8601 timestamp (e.g., 2025-11-07T10:00:00Z) (required)")
+	onetimeCreateCmd.Flags().String("execute-at", "", "When to execute (required)\n  Examples: 'in 5 minutes', 'tomorrow at 9am', 'next monday at 3pm', '2025-11-07T10:00:00Z'")
 
 	onetimeCmd.AddCommand(onetimeListCmd)
 	onetimeCmd.AddCommand(onetimeGetCmd)

+ 9 - 2
cli/cmd/recurring.go

@@ -7,6 +7,7 @@ import (
 	"github.com/fatih/color"
 	"github.com/letta/letta-schedules-cli/internal/client"
 	"github.com/letta/letta-schedules-cli/internal/config"
+	"github.com/letta/letta-schedules-cli/internal/parser"
 	"github.com/olekukonko/tablewriter"
 	"github.com/spf13/cobra"
 )
@@ -30,6 +31,12 @@ var recurringCreateCmd = &cobra.Command{
 			return fmt.Errorf("agent-id, message, and cron are required")
 		}
 
+		// Parse natural language to cron expression
+		parsedCron, err := parser.ParseCron(cronString)
+		if err != nil {
+			return fmt.Errorf("failed to parse cron: %w", err)
+		}
+
 		cfg, err := config.Load()
 		if err != nil {
 			return err
@@ -43,7 +50,7 @@ var recurringCreateCmd = &cobra.Command{
 			AgentID:    agentID,
 			Message:    message,
 			Role:       role,
-			CronString: cronString,
+			CronString: parsedCron,
 		})
 		if err != nil {
 			return fmt.Errorf("failed to create schedule: %w", err)
@@ -184,7 +191,7 @@ func init() {
 	recurringCreateCmd.Flags().String("agent-id", "", "Agent ID (required)")
 	recurringCreateCmd.Flags().String("message", "", "Message to send (required)")
 	recurringCreateCmd.Flags().String("role", "user", "Message role (default: user)")
-	recurringCreateCmd.Flags().String("cron", "", "Cron expression (required)")
+	recurringCreateCmd.Flags().String("cron", "", "Schedule pattern (required)\n  Examples: 'every 5 minutes', 'daily at 9am', 'every monday at 3pm', '*/5 * * * *'")
 
 	recurringCmd.AddCommand(recurringListCmd)
 	recurringCmd.AddCommand(recurringGetCmd)

+ 178 - 0
cli/internal/parser/cron_parser.go

@@ -0,0 +1,178 @@
+package parser
+
+import (
+	"fmt"
+	"regexp"
+	"strconv"
+	"strings"
+)
+
+// ParseCron converts natural language to cron expression
+func ParseCron(input string) (string, error) {
+	input = strings.TrimSpace(strings.ToLower(input))
+	
+	// If it already looks like a cron expression, return as-is
+	if isCronExpression(input) {
+		return input, nil
+	}
+	
+	// "every X minutes"
+	if strings.HasPrefix(input, "every ") && strings.Contains(input, "minute") {
+		return parseEveryMinutes(input)
+	}
+	
+	// "every hour" or "hourly"
+	if input == "every hour" || input == "hourly" {
+		return "0 * * * *", nil
+	}
+	
+	// "every day" or "daily"
+	if input == "every day" || input == "daily" {
+		return "0 9 * * *", nil // 9am daily
+	}
+	
+	// "daily at HH:MM"
+	if strings.HasPrefix(input, "daily at ") {
+		return parseDailyAt(input)
+	}
+	
+	// "every monday/tuesday/etc"
+	if strings.HasPrefix(input, "every ") && containsWeekday(input) {
+		return parseEveryWeekday(input)
+	}
+	
+	// "every weekday"
+	if input == "every weekday" || input == "weekdays" {
+		return "0 9 * * 1-5", nil // 9am Mon-Fri
+	}
+	
+	// "every weekend"
+	if input == "every weekend" || input == "weekends" {
+		return "0 9 * * 0,6", nil // 9am Sat-Sun
+	}
+	
+	// "monthly"
+	if input == "monthly" {
+		return "0 9 1 * *", nil // 9am on 1st of month
+	}
+	
+	// "weekly"
+	if input == "weekly" {
+		return "0 9 * * 1", nil // 9am every Monday
+	}
+	
+	return "", fmt.Errorf("unable to parse cron: %s\n\nSupported formats:\n  - Cron: */5 * * * * (every 5 min)\n  - Minutes: every 5 minutes, every 30 minutes\n  - Hourly: every hour, hourly\n  - Daily: daily, daily at 9am, daily at 14:30\n  - Weekday: every monday, every friday at 3pm\n  - Weekdays: every weekday, weekdays (Mon-Fri at 9am)\n  - Weekly: weekly (every Monday at 9am)\n  - Monthly: monthly (1st of month at 9am)", input)
+}
+
+func parseEveryMinutes(input string) (string, error) {
+	// "every 5 minutes", "every 30 minutes"
+	re := regexp.MustCompile(`^every\s+(\d+)\s+minutes?$`)
+	matches := re.FindStringSubmatch(input)
+	
+	if len(matches) != 2 {
+		return "", fmt.Errorf("invalid format: %s (expected: every X minutes)", input)
+	}
+	
+	minutes, _ := strconv.Atoi(matches[1])
+	if minutes <= 0 || minutes > 59 {
+		return "", fmt.Errorf("minutes must be between 1 and 59")
+	}
+	
+	return fmt.Sprintf("*/%d * * * *", minutes), nil
+}
+
+func parseDailyAt(input string) (string, error) {
+	// "daily at 9am", "daily at 14:30"
+	timeStr := strings.TrimPrefix(input, "daily at ")
+	timeStr = strings.TrimSpace(timeStr)
+	
+	hour, minute, err := parseTimeOfDay(timeStr)
+	if err != nil {
+		return "", err
+	}
+	
+	return fmt.Sprintf("%d %d * * *", minute, hour), nil
+}
+
+func parseEveryWeekday(input string) (string, error) {
+	// "every monday", "every friday at 3pm"
+	re := regexp.MustCompile(`^every\s+(monday|tuesday|wednesday|thursday|friday|saturday|sunday)(?:\s+at\s+(.+))?$`)
+	matches := re.FindStringSubmatch(input)
+	
+	if len(matches) < 2 {
+		return "", fmt.Errorf("invalid format: %s", input)
+	}
+	
+	dayName := matches[1]
+	timeStr := ""
+	if len(matches) > 2 {
+		timeStr = matches[2]
+	}
+	
+	// Get weekday number (0=Sunday, 1=Monday, etc.)
+	weekdayNum := getWeekdayNumber(dayName)
+	
+	// Default to 9am if no time specified
+	hour := 9
+	minute := 0
+	
+	if timeStr != "" {
+		var err error
+		hour, minute, err = parseTimeOfDay(timeStr)
+		if err != nil {
+			return "", err
+		}
+	}
+	
+	return fmt.Sprintf("%d %d * * %d", minute, hour, weekdayNum), nil
+}
+
+func isCronExpression(input string) bool {
+	// Basic check for cron pattern (5 fields separated by spaces)
+	parts := strings.Fields(input)
+	if len(parts) != 5 {
+		return false
+	}
+	
+	// Check if fields look cron-like
+	cronPattern := regexp.MustCompile(`^[\d\*\-,/]+$`)
+	for _, part := range parts {
+		if !cronPattern.MatchString(part) {
+			return false
+		}
+	}
+	
+	return true
+}
+
+func containsWeekday(input string) bool {
+	weekdays := []string{"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"}
+	for _, day := range weekdays {
+		if strings.Contains(input, day) {
+			return true
+		}
+	}
+	return false
+}
+
+func getWeekdayNumber(day string) int {
+	// Cron weekday numbers: 0=Sunday, 1=Monday, ..., 6=Saturday
+	switch strings.ToLower(day) {
+	case "sunday":
+		return 0
+	case "monday":
+		return 1
+	case "tuesday":
+		return 2
+	case "wednesday":
+		return 3
+	case "thursday":
+		return 4
+	case "friday":
+		return 5
+	case "saturday":
+		return 6
+	default:
+		return 1
+	}
+}

+ 199 - 0
cli/internal/parser/time_parser.go

@@ -0,0 +1,199 @@
+package parser
+
+import (
+	"fmt"
+	"regexp"
+	"strconv"
+	"strings"
+	"time"
+)
+
+// ParseTime converts natural language or ISO 8601 timestamps to ISO 8601 format
+func ParseTime(input string) (string, error) {
+	input = strings.TrimSpace(strings.ToLower(input))
+	
+	// Try parsing as ISO 8601 first
+	formats := []string{
+		time.RFC3339,
+		"2006-01-02T15:04:05Z",
+		"2006-01-02 15:04:05",
+		"2006-01-02 15:04",
+		"2006-01-02T15:04",
+	}
+	
+	for _, format := range formats {
+		if t, err := time.Parse(format, input); err == nil {
+			return t.UTC().Format(time.RFC3339), nil
+		}
+	}
+	
+	now := time.Now().UTC()
+	
+	// "in X minutes/hours/days"
+	if strings.HasPrefix(input, "in ") {
+		return parseRelativeTime(input, now)
+	}
+	
+	// "tomorrow at HH:MM"
+	if strings.HasPrefix(input, "tomorrow") {
+		return parseTomorrow(input, now)
+	}
+	
+	// "next monday/tuesday/etc at HH:MM"
+	if strings.HasPrefix(input, "next ") {
+		return parseNextDay(input, now)
+	}
+	
+	// "now"
+	if input == "now" {
+		return now.Format(time.RFC3339), nil
+	}
+	
+	return "", fmt.Errorf("unable to parse time: %s\n\nSupported formats:\n  - ISO 8601: 2025-11-12T19:30:00Z\n  - Relative: in 5 minutes, in 2 hours, in 3 days\n  - Tomorrow: tomorrow at 9am, tomorrow at 14:30\n  - Next day: next monday at 3pm, next friday at 10:00\n  - Now: now", input)
+}
+
+func parseRelativeTime(input string, now time.Time) (string, error) {
+	// "in 5 minutes", "in 2 hours", "in 3 days"
+	re := regexp.MustCompile(`^in (\d+)\s*(minute|minutes|min|hour|hours|hr|hrs|h|day|days|d)s?$`)
+	matches := re.FindStringSubmatch(input)
+	
+	if len(matches) != 3 {
+		return "", fmt.Errorf("invalid relative time format: %s", input)
+	}
+	
+	value, _ := strconv.Atoi(matches[1])
+	unit := matches[2]
+	
+	var t time.Time
+	switch {
+	case strings.HasPrefix(unit, "min"):
+		t = now.Add(time.Duration(value) * time.Minute)
+	case strings.HasPrefix(unit, "h"):
+		t = now.Add(time.Duration(value) * time.Hour)
+	case strings.HasPrefix(unit, "d"):
+		t = now.AddDate(0, 0, value)
+	default:
+		return "", fmt.Errorf("unknown time unit: %s", unit)
+	}
+	
+	return t.Format(time.RFC3339), nil
+}
+
+func parseTomorrow(input string, now time.Time) (string, error) {
+	// "tomorrow" or "tomorrow at 9am" or "tomorrow at 14:30"
+	tomorrow := now.AddDate(0, 0, 1)
+	
+	if input == "tomorrow" {
+		// Default to 9am tomorrow
+		tomorrow = time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 9, 0, 0, 0, time.UTC)
+		return tomorrow.Format(time.RFC3339), nil
+	}
+	
+	// Parse "tomorrow at HH:MM" or "tomorrow at 9am"
+	atIndex := strings.Index(input, " at ")
+	if atIndex == -1 {
+		return "", fmt.Errorf("expected 'at' in: %s", input)
+	}
+	
+	timeStr := strings.TrimSpace(input[atIndex+4:])
+	hour, minute, err := parseTimeOfDay(timeStr)
+	if err != nil {
+		return "", err
+	}
+	
+	t := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), hour, minute, 0, 0, time.UTC)
+	return t.Format(time.RFC3339), nil
+}
+
+func parseNextDay(input string, now time.Time) (string, error) {
+	// "next monday at 3pm", "next friday at 10:00"
+	re := regexp.MustCompile(`^next\s+(monday|tuesday|wednesday|thursday|friday|saturday|sunday)\s+at\s+(.+)$`)
+	matches := re.FindStringSubmatch(input)
+	
+	if len(matches) != 3 {
+		return "", fmt.Errorf("expected format 'next DAY at TIME': %s", input)
+	}
+	
+	dayName := matches[1]
+	timeStr := matches[2]
+	
+	// Find target weekday
+	targetWeekday := parseWeekday(dayName)
+	currentWeekday := now.Weekday()
+	
+	daysUntil := int(targetWeekday - currentWeekday)
+	if daysUntil <= 0 {
+		daysUntil += 7
+	}
+	
+	targetDate := now.AddDate(0, 0, daysUntil)
+	
+	hour, minute, err := parseTimeOfDay(timeStr)
+	if err != nil {
+		return "", err
+	}
+	
+	t := time.Date(targetDate.Year(), targetDate.Month(), targetDate.Day(), hour, minute, 0, 0, time.UTC)
+	return t.Format(time.RFC3339), nil
+}
+
+func parseTimeOfDay(input string) (hour int, minute int, err error) {
+	input = strings.TrimSpace(strings.ToLower(input))
+	
+	// Handle special cases
+	switch input {
+	case "noon", "12pm":
+		return 12, 0, nil
+	case "midnight", "12am":
+		return 0, 0, nil
+	}
+	
+	// Parse "3pm", "9am"
+	re := regexp.MustCompile(`^(\d+)(am|pm)$`)
+	matches := re.FindStringSubmatch(input)
+	if len(matches) == 3 {
+		h, _ := strconv.Atoi(matches[1])
+		if matches[2] == "pm" && h != 12 {
+			h += 12
+		}
+		if matches[2] == "am" && h == 12 {
+			h = 0
+		}
+		return h, 0, nil
+	}
+	
+	// Parse "14:30", "9:15"
+	re = regexp.MustCompile(`^(\d+):(\d+)$`)
+	matches = re.FindStringSubmatch(input)
+	if len(matches) == 3 {
+		h, _ := strconv.Atoi(matches[1])
+		m, _ := strconv.Atoi(matches[2])
+		if h > 23 || m > 59 {
+			return 0, 0, fmt.Errorf("invalid time: %s", input)
+		}
+		return h, m, nil
+	}
+	
+	return 0, 0, fmt.Errorf("unable to parse time of day: %s", input)
+}
+
+func parseWeekday(day string) time.Weekday {
+	switch strings.ToLower(day) {
+	case "sunday":
+		return time.Sunday
+	case "monday":
+		return time.Monday
+	case "tuesday":
+		return time.Tuesday
+	case "wednesday":
+		return time.Wednesday
+	case "thursday":
+		return time.Thursday
+	case "friday":
+		return time.Friday
+	case "saturday":
+		return time.Saturday
+	default:
+		return time.Monday
+	}
+}