language-operator 0.1.31 → 0.1.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +7 -8
- data/CHANGELOG.md +14 -0
- data/CI_STATUS.md +56 -0
- data/Gemfile.lock +2 -2
- data/Makefile +22 -6
- data/lib/language_operator/agent/base.rb +10 -6
- data/lib/language_operator/agent/executor.rb +19 -97
- data/lib/language_operator/agent/safety/ast_validator.rb +62 -43
- data/lib/language_operator/agent/safety/safe_executor.rb +27 -2
- data/lib/language_operator/agent/scheduler.rb +60 -0
- data/lib/language_operator/agent/task_executor.rb +548 -0
- data/lib/language_operator/agent.rb +90 -27
- data/lib/language_operator/cli/base_command.rb +117 -0
- data/lib/language_operator/cli/commands/agent.rb +339 -407
- data/lib/language_operator/cli/commands/cluster.rb +274 -290
- data/lib/language_operator/cli/commands/install.rb +110 -119
- data/lib/language_operator/cli/commands/model.rb +284 -184
- data/lib/language_operator/cli/commands/persona.rb +218 -284
- data/lib/language_operator/cli/commands/quickstart.rb +4 -5
- data/lib/language_operator/cli/commands/status.rb +31 -35
- data/lib/language_operator/cli/commands/system.rb +221 -233
- data/lib/language_operator/cli/commands/tool.rb +356 -422
- data/lib/language_operator/cli/commands/use.rb +19 -22
- data/lib/language_operator/cli/helpers/resource_dependency_checker.rb +0 -18
- data/lib/language_operator/cli/wizards/quickstart_wizard.rb +0 -1
- data/lib/language_operator/client/config.rb +20 -21
- data/lib/language_operator/config.rb +115 -3
- data/lib/language_operator/constants.rb +54 -0
- data/lib/language_operator/dsl/agent_context.rb +7 -7
- data/lib/language_operator/dsl/agent_definition.rb +111 -26
- data/lib/language_operator/dsl/config.rb +30 -66
- data/lib/language_operator/dsl/main_definition.rb +114 -0
- data/lib/language_operator/dsl/schema.rb +84 -43
- data/lib/language_operator/dsl/task_definition.rb +315 -0
- data/lib/language_operator/dsl.rb +0 -1
- data/lib/language_operator/instrumentation/task_tracer.rb +285 -0
- data/lib/language_operator/logger.rb +4 -4
- data/lib/language_operator/synthesis_test_harness.rb +324 -0
- data/lib/language_operator/templates/examples/agent_synthesis.tmpl +26 -8
- data/lib/language_operator/templates/schema/CHANGELOG.md +26 -0
- data/lib/language_operator/templates/schema/agent_dsl_openapi.yaml +1 -1
- data/lib/language_operator/templates/schema/agent_dsl_schema.json +84 -42
- data/lib/language_operator/type_coercion.rb +250 -0
- data/lib/language_operator/ux/base.rb +81 -0
- data/lib/language_operator/ux/concerns/README.md +155 -0
- data/lib/language_operator/ux/concerns/headings.rb +90 -0
- data/lib/language_operator/ux/concerns/input_validation.rb +146 -0
- data/lib/language_operator/ux/concerns/provider_helpers.rb +167 -0
- data/lib/language_operator/ux/create_agent.rb +252 -0
- data/lib/language_operator/ux/create_model.rb +267 -0
- data/lib/language_operator/ux/quickstart.rb +594 -0
- data/lib/language_operator/version.rb +1 -1
- data/lib/language_operator.rb +2 -0
- data/requirements/ARCHITECTURE.md +1 -0
- data/requirements/SCRATCH.md +153 -0
- data/requirements/dsl.md +0 -0
- data/requirements/features +1 -0
- data/requirements/personas +1 -0
- data/requirements/proposals +1 -0
- data/requirements/tasks/iterate.md +14 -15
- data/requirements/tasks/optimize.md +13 -4
- data/synth/001/Makefile +90 -0
- data/synth/001/agent.rb +26 -0
- data/synth/001/agent.yaml +7 -0
- data/synth/001/output.log +44 -0
- data/synth/Makefile +39 -0
- data/synth/README.md +342 -0
- metadata +37 -10
- data/lib/language_operator/dsl/workflow_definition.rb +0 -259
- data/test_agent_dsl.rb +0 -108
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LanguageOperator
|
|
4
|
+
module Ux
|
|
5
|
+
module Concerns
|
|
6
|
+
# Mixin for common input validation and prompting patterns
|
|
7
|
+
#
|
|
8
|
+
# Provides helpers for common validation scenarios like URLs, emails,
|
|
9
|
+
# Kubernetes resource names, and other frequently validated inputs.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# class MyFlow < Base
|
|
13
|
+
# include Concerns::InputValidation
|
|
14
|
+
#
|
|
15
|
+
# def execute
|
|
16
|
+
# url = ask_url('Enter endpoint URL:')
|
|
17
|
+
# name = ask_k8s_name('Resource name:')
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
module InputValidation
|
|
21
|
+
# Ask for a URL with validation
|
|
22
|
+
#
|
|
23
|
+
# @param question [String] The prompt question
|
|
24
|
+
# @param default [String, nil] Default value
|
|
25
|
+
# @param required [Boolean] Whether input is required
|
|
26
|
+
# @return [String, nil] The validated URL or nil if cancelled
|
|
27
|
+
def ask_url(question, default: nil, required: true)
|
|
28
|
+
prompt.ask(question, default: default) do |q|
|
|
29
|
+
q.required required
|
|
30
|
+
q.validate(%r{^https?://})
|
|
31
|
+
q.messages[:valid?] = 'Must be a valid HTTP(S) URL'
|
|
32
|
+
end
|
|
33
|
+
rescue TTY::Reader::InputInterrupt
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Ask for a Kubernetes-compatible resource name
|
|
38
|
+
#
|
|
39
|
+
# @param question [String] The prompt question
|
|
40
|
+
# @param default [String, nil] Default value
|
|
41
|
+
# @return [String, nil] The validated name or nil if cancelled
|
|
42
|
+
def ask_k8s_name(question, default: nil)
|
|
43
|
+
prompt.ask(question, default: default) do |q|
|
|
44
|
+
q.required true
|
|
45
|
+
q.validate(/^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/, 'Must be lowercase alphanumeric with hyphens')
|
|
46
|
+
q.modify :strip, :down
|
|
47
|
+
end
|
|
48
|
+
rescue TTY::Reader::InputInterrupt
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Ask for an email address with validation
|
|
53
|
+
#
|
|
54
|
+
# @param question [String] The prompt question
|
|
55
|
+
# @param default [String, nil] Default value
|
|
56
|
+
# @return [String, nil] The validated email or nil if cancelled
|
|
57
|
+
def ask_email(question, default: nil)
|
|
58
|
+
prompt.ask(question, default: default) do |q|
|
|
59
|
+
q.required true
|
|
60
|
+
q.validate(/\A[\w+\-.]+@[a-z\d-]+(\.[a-z\d-]+)*\.[a-z]+\z/i)
|
|
61
|
+
q.messages[:valid?] = 'Please enter a valid email address'
|
|
62
|
+
end
|
|
63
|
+
rescue TTY::Reader::InputInterrupt
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Ask for a masked input (like API keys or passwords)
|
|
68
|
+
#
|
|
69
|
+
# @param question [String] The prompt question
|
|
70
|
+
# @param required [Boolean] Whether input is required
|
|
71
|
+
# @return [String, nil] The input value or nil if cancelled
|
|
72
|
+
def ask_secret(question, required: true)
|
|
73
|
+
prompt.mask(question) do |q|
|
|
74
|
+
q.required required
|
|
75
|
+
end
|
|
76
|
+
rescue TTY::Reader::InputInterrupt
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Ask for a port number with validation
|
|
81
|
+
#
|
|
82
|
+
# @param question [String] The prompt question
|
|
83
|
+
# @param default [Integer, nil] Default value
|
|
84
|
+
# @return [Integer, nil] The validated port or nil if cancelled
|
|
85
|
+
def ask_port(question, default: nil)
|
|
86
|
+
prompt.ask(question, default: default, convert: :int) do |q|
|
|
87
|
+
q.required true
|
|
88
|
+
q.validate(->(v) { v.to_i.between?(1, 65_535) })
|
|
89
|
+
q.messages[:valid?] = 'Must be a valid port number (1-65535)'
|
|
90
|
+
end
|
|
91
|
+
rescue TTY::Reader::InputInterrupt
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Ask a yes/no question
|
|
96
|
+
#
|
|
97
|
+
# @param question [String] The prompt question
|
|
98
|
+
# @param default [Boolean] Default value
|
|
99
|
+
# @return [Boolean, nil] The response or nil if cancelled
|
|
100
|
+
def ask_yes_no(question, default: false)
|
|
101
|
+
prompt.yes?(question, default: default)
|
|
102
|
+
rescue TTY::Reader::InputInterrupt
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Ask for selection from a list
|
|
107
|
+
#
|
|
108
|
+
# @param question [String] The prompt question
|
|
109
|
+
# @param choices [Array] Array of choices
|
|
110
|
+
# @param per_page [Integer] Items per page for pagination
|
|
111
|
+
# @return [Object, nil] The selected choice or nil if cancelled
|
|
112
|
+
def ask_select(question, choices, per_page: 10)
|
|
113
|
+
prompt.select(question, choices, per_page: per_page)
|
|
114
|
+
rescue TTY::Reader::InputInterrupt
|
|
115
|
+
nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Validate and coerce a Kubernetes resource name
|
|
119
|
+
#
|
|
120
|
+
# @param name [String] The name to validate
|
|
121
|
+
# @return [String] The validated and normalized name
|
|
122
|
+
# @raise [ArgumentError] If name is invalid
|
|
123
|
+
def validate_k8s_name(name)
|
|
124
|
+
normalized = name.to_s.downcase.strip
|
|
125
|
+
raise ArgumentError, "Invalid Kubernetes name: #{name}" unless normalized.match?(/^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/)
|
|
126
|
+
|
|
127
|
+
normalized
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Validate a URL
|
|
131
|
+
#
|
|
132
|
+
# @param url [String] The URL to validate
|
|
133
|
+
# @return [String] The validated URL
|
|
134
|
+
# @raise [ArgumentError] If URL is invalid
|
|
135
|
+
def validate_url(url)
|
|
136
|
+
uri = URI.parse(url)
|
|
137
|
+
raise ArgumentError, "Invalid URL: #{url}" unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
|
138
|
+
|
|
139
|
+
url
|
|
140
|
+
rescue URI::InvalidURIError => e
|
|
141
|
+
raise ArgumentError, "Invalid URL: #{e.message}"
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
module LanguageOperator
|
|
8
|
+
module Ux
|
|
9
|
+
module Concerns
|
|
10
|
+
# Mixin for common LLM provider operations
|
|
11
|
+
#
|
|
12
|
+
# Provides helpers for testing provider connections, fetching available models,
|
|
13
|
+
# and handling provider-specific configuration.
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# class MyFlow < Base
|
|
17
|
+
# include Concerns::ProviderHelpers
|
|
18
|
+
#
|
|
19
|
+
# def setup
|
|
20
|
+
# result = test_provider_connection(:anthropic, api_key: 'sk-...')
|
|
21
|
+
# models = fetch_provider_models(:openai, api_key: 'sk-...')
|
|
22
|
+
# end
|
|
23
|
+
# end
|
|
24
|
+
module ProviderHelpers
|
|
25
|
+
# Test connection to an LLM provider
|
|
26
|
+
#
|
|
27
|
+
# @param provider [Symbol] Provider type (:anthropic, :openai, :openai_compatible)
|
|
28
|
+
# @param api_key [String, nil] API key for authentication
|
|
29
|
+
# @param endpoint [String, nil] Custom endpoint URL (for openai_compatible)
|
|
30
|
+
# @return [Hash] Result with :success and optional :error keys
|
|
31
|
+
def test_provider_connection(provider, api_key: nil, endpoint: nil)
|
|
32
|
+
require 'ruby_llm'
|
|
33
|
+
|
|
34
|
+
case provider
|
|
35
|
+
when :anthropic
|
|
36
|
+
test_anthropic(api_key)
|
|
37
|
+
when :openai
|
|
38
|
+
test_openai(api_key)
|
|
39
|
+
when :openai_compatible
|
|
40
|
+
test_openai_compatible(endpoint, api_key)
|
|
41
|
+
else
|
|
42
|
+
{ success: false, error: "Unknown provider: #{provider}" }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Fetch available models from a provider
|
|
47
|
+
#
|
|
48
|
+
# @param provider [Symbol] Provider type
|
|
49
|
+
# @param api_key [String, nil] API key for authentication
|
|
50
|
+
# @param endpoint [String, nil] Custom endpoint URL
|
|
51
|
+
# @return [Array<String>, nil] List of model IDs or nil if unavailable
|
|
52
|
+
def fetch_provider_models(provider, api_key: nil, endpoint: nil)
|
|
53
|
+
case provider
|
|
54
|
+
when :anthropic
|
|
55
|
+
# Anthropic doesn't have a public /v1/models endpoint
|
|
56
|
+
[
|
|
57
|
+
'claude-3-5-sonnet-20241022',
|
|
58
|
+
'claude-3-opus-20240229',
|
|
59
|
+
'claude-3-sonnet-20240229',
|
|
60
|
+
'claude-3-haiku-20240307'
|
|
61
|
+
]
|
|
62
|
+
when :openai
|
|
63
|
+
fetch_openai_models(api_key)
|
|
64
|
+
when :openai_compatible
|
|
65
|
+
fetch_openai_compatible_models(endpoint, api_key)
|
|
66
|
+
end
|
|
67
|
+
rescue StandardError => e
|
|
68
|
+
CLI::Formatters::ProgressFormatter.warn("Could not fetch models: #{e.message}")
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Get provider display information
|
|
73
|
+
#
|
|
74
|
+
# @param provider [Symbol] Provider type
|
|
75
|
+
# @return [Hash] Hash with :name, :docs_url keys
|
|
76
|
+
def provider_info(provider)
|
|
77
|
+
case provider
|
|
78
|
+
when :anthropic
|
|
79
|
+
{ name: 'Anthropic', docs_url: 'https://console.anthropic.com' }
|
|
80
|
+
when :openai
|
|
81
|
+
{ name: 'OpenAI', docs_url: 'https://platform.openai.com/api-keys' }
|
|
82
|
+
when :openai_compatible
|
|
83
|
+
{ name: 'OpenAI-Compatible', docs_url: nil }
|
|
84
|
+
else
|
|
85
|
+
{ name: provider.to_s.capitalize, docs_url: nil }
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def test_anthropic(api_key)
|
|
92
|
+
client = RubyLLM.new(provider: :anthropic, api_key: api_key)
|
|
93
|
+
client.chat(
|
|
94
|
+
[{ role: 'user', content: 'Test' }],
|
|
95
|
+
model: 'claude-3-5-sonnet-20241022',
|
|
96
|
+
max_tokens: 10
|
|
97
|
+
)
|
|
98
|
+
{ success: true }
|
|
99
|
+
rescue StandardError => e
|
|
100
|
+
{ success: false, error: e.message }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def test_openai(api_key)
|
|
104
|
+
client = RubyLLM.new(provider: :openai, api_key: api_key)
|
|
105
|
+
client.chat(
|
|
106
|
+
[{ role: 'user', content: 'Test' }],
|
|
107
|
+
model: 'gpt-4-turbo',
|
|
108
|
+
max_tokens: 10
|
|
109
|
+
)
|
|
110
|
+
{ success: true }
|
|
111
|
+
rescue StandardError => e
|
|
112
|
+
{ success: false, error: e.message }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def test_openai_compatible(endpoint, api_key = nil)
|
|
116
|
+
# For OpenAI-compatible endpoints, we don't make a test request
|
|
117
|
+
# as we can't know what model to use. Just verify the endpoint is reachable.
|
|
118
|
+
fetch_openai_compatible_models(endpoint, api_key)
|
|
119
|
+
{ success: true }
|
|
120
|
+
rescue StandardError => e
|
|
121
|
+
{ success: false, error: e.message }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def fetch_openai_models(api_key)
|
|
125
|
+
fetch_models_from_endpoint('https://api.openai.com', api_key)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def fetch_openai_compatible_models(endpoint, api_key)
|
|
129
|
+
return nil unless endpoint
|
|
130
|
+
|
|
131
|
+
fetch_models_from_endpoint(endpoint, api_key)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def fetch_models_from_endpoint(base_url, api_key)
|
|
135
|
+
models_url = URI.join(
|
|
136
|
+
base_url.end_with?('/') ? base_url : "#{base_url}/",
|
|
137
|
+
'v1/models'
|
|
138
|
+
).to_s
|
|
139
|
+
|
|
140
|
+
uri = URI(models_url)
|
|
141
|
+
request = Net::HTTP::Get.new(uri)
|
|
142
|
+
request['Authorization'] = "Bearer #{api_key}" if api_key
|
|
143
|
+
request['Content-Type'] = 'application/json'
|
|
144
|
+
|
|
145
|
+
response = Net::HTTP.start(
|
|
146
|
+
uri.hostname,
|
|
147
|
+
uri.port,
|
|
148
|
+
use_ssl: uri.scheme == 'https',
|
|
149
|
+
read_timeout: 10
|
|
150
|
+
) do |http|
|
|
151
|
+
http.request(request)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
return nil unless response.is_a?(Net::HTTPSuccess)
|
|
155
|
+
|
|
156
|
+
data = JSON.parse(response.body)
|
|
157
|
+
models = data['data']&.map { |m| m['id'] } || []
|
|
158
|
+
|
|
159
|
+
# Filter out fine-tuned/snapshot models for better UX
|
|
160
|
+
models.reject { |m| m.include?('ft-') || m.include?(':') }
|
|
161
|
+
rescue StandardError
|
|
162
|
+
nil
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
require_relative '../cli/helpers/schedule_builder'
|
|
5
|
+
|
|
6
|
+
module LanguageOperator
|
|
7
|
+
module Ux
|
|
8
|
+
# Interactive flow for creating agents
|
|
9
|
+
#
|
|
10
|
+
# Guides users through task description, scheduling, and tool configuration.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# description = Ux::CreateAgent.execute(ctx)
|
|
14
|
+
#
|
|
15
|
+
# rubocop:disable Metrics/ClassLength, Metrics/AbcSize
|
|
16
|
+
class CreateAgent < Base
|
|
17
|
+
# Execute the agent creation flow
|
|
18
|
+
#
|
|
19
|
+
# @return [String, nil] Generated description or nil if cancelled
|
|
20
|
+
def execute
|
|
21
|
+
show_welcome
|
|
22
|
+
|
|
23
|
+
# Step 1: Get task description
|
|
24
|
+
task = ask_task_description
|
|
25
|
+
return nil unless task
|
|
26
|
+
|
|
27
|
+
# Step 2: Determine schedule
|
|
28
|
+
schedule_info = ask_schedule
|
|
29
|
+
return nil unless schedule_info
|
|
30
|
+
|
|
31
|
+
# Step 3: Tool detection and configuration
|
|
32
|
+
tools_config = configure_tools(task)
|
|
33
|
+
|
|
34
|
+
# Step 4: Preview and confirm
|
|
35
|
+
description = build_description(task, schedule_info, tools_config)
|
|
36
|
+
|
|
37
|
+
show_preview(description, schedule_info, tools_config)
|
|
38
|
+
|
|
39
|
+
return nil unless confirm_creation?
|
|
40
|
+
|
|
41
|
+
description
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def show_welcome
|
|
47
|
+
heading('Model quick start')
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def ask_task_description
|
|
51
|
+
prompt.ask('What should your agent do?') do |q|
|
|
52
|
+
q.required true
|
|
53
|
+
q.validate(/\w+/)
|
|
54
|
+
q.messages[:valid?] = 'Please describe a task (cannot be empty)'
|
|
55
|
+
end
|
|
56
|
+
rescue TTY::Reader::InputInterrupt
|
|
57
|
+
CLI::Formatters::ProgressFormatter.error('Cancelled')
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def ask_schedule
|
|
62
|
+
puts
|
|
63
|
+
schedule_type = prompt.select('How often should it run?') do |menu|
|
|
64
|
+
menu.choice 'Every day at a specific time', :daily
|
|
65
|
+
menu.choice 'Every few minutes/hours', :interval
|
|
66
|
+
menu.choice 'Continuously (whenever something changes)', :continuous
|
|
67
|
+
menu.choice 'Only when I trigger it manually', :manual
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
case schedule_type
|
|
71
|
+
when :daily
|
|
72
|
+
ask_daily_schedule
|
|
73
|
+
when :interval
|
|
74
|
+
ask_interval_schedule
|
|
75
|
+
when :continuous
|
|
76
|
+
{ type: :continuous, description: 'continuously' }
|
|
77
|
+
when :manual
|
|
78
|
+
{ type: :manual, description: 'on manual trigger' }
|
|
79
|
+
end
|
|
80
|
+
rescue TTY::Reader::InputInterrupt
|
|
81
|
+
CLI::Formatters::ProgressFormatter.error('Cancelled')
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def ask_daily_schedule
|
|
86
|
+
puts
|
|
87
|
+
time_input = prompt.ask('What time each day? (e.g., 4pm, 9:30am, 16:00):') do |q|
|
|
88
|
+
q.required true
|
|
89
|
+
q.validate(lambda do |input|
|
|
90
|
+
CLI::Helpers::ScheduleBuilder.parse_time(input)
|
|
91
|
+
true
|
|
92
|
+
rescue ArgumentError
|
|
93
|
+
false
|
|
94
|
+
end)
|
|
95
|
+
q.messages[:valid?] = 'Invalid time format. Try: 4pm, 9:30am, or 16:00'
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
time_24h = CLI::Helpers::ScheduleBuilder.parse_time(time_input)
|
|
99
|
+
cron = CLI::Helpers::ScheduleBuilder.daily_cron(time_24h)
|
|
100
|
+
|
|
101
|
+
{
|
|
102
|
+
type: :daily,
|
|
103
|
+
time: time_24h,
|
|
104
|
+
cron: cron,
|
|
105
|
+
description: "daily at #{time_input}"
|
|
106
|
+
}
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def ask_interval_schedule
|
|
110
|
+
puts
|
|
111
|
+
interval = prompt.ask('How often?', convert: :int) do |q|
|
|
112
|
+
q.required true
|
|
113
|
+
q.validate(->(v) { v.to_i.positive? })
|
|
114
|
+
q.messages[:valid?] = 'Please enter a positive number'
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
unit = prompt.select('Minutes, hours, or days?', %w[minutes hours days])
|
|
118
|
+
|
|
119
|
+
cron = CLI::Helpers::ScheduleBuilder.interval_cron(interval, unit)
|
|
120
|
+
|
|
121
|
+
{
|
|
122
|
+
type: :interval,
|
|
123
|
+
interval: interval,
|
|
124
|
+
unit: unit,
|
|
125
|
+
cron: cron,
|
|
126
|
+
description: "every #{interval} #{unit}"
|
|
127
|
+
}
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def configure_tools(task_description)
|
|
131
|
+
detected = detect_tools(task_description)
|
|
132
|
+
config = {}
|
|
133
|
+
|
|
134
|
+
return config if detected.empty?
|
|
135
|
+
|
|
136
|
+
puts
|
|
137
|
+
puts "I detected these tools: #{pastel.yellow(detected.join(', '))}"
|
|
138
|
+
puts
|
|
139
|
+
|
|
140
|
+
detected.each do |tool|
|
|
141
|
+
case tool
|
|
142
|
+
when 'email'
|
|
143
|
+
config[:email] = ask_email_config
|
|
144
|
+
when 'google-sheets', 'spreadsheet'
|
|
145
|
+
config[:spreadsheet] = ask_spreadsheet_config
|
|
146
|
+
when 'slack'
|
|
147
|
+
config[:slack] = ask_slack_config
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
config[:tools] = detected
|
|
152
|
+
config
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def detect_tools(description)
|
|
156
|
+
tools = []
|
|
157
|
+
text = description.downcase
|
|
158
|
+
|
|
159
|
+
tools << 'email' if text.match?(/email|mail|send.*message/i)
|
|
160
|
+
tools << 'google-sheets' if text.match?(/spreadsheet|sheet|excel|csv/i)
|
|
161
|
+
tools << 'slack' if text.match?(/slack/i)
|
|
162
|
+
tools << 'github' if text.match?(/github|git|repo/i)
|
|
163
|
+
|
|
164
|
+
tools
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def ask_email_config
|
|
168
|
+
email = prompt.ask('Your email for notifications:') do |q|
|
|
169
|
+
q.required true
|
|
170
|
+
q.validate(/\A[\w+\-.]+@[a-z\d-]+(\.[a-z\d-]+)*\.[a-z]+\z/i)
|
|
171
|
+
q.messages[:valid?] = 'Please enter a valid email address'
|
|
172
|
+
end
|
|
173
|
+
{ address: email }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def ask_spreadsheet_config
|
|
177
|
+
url = prompt.ask('Spreadsheet URL:') do |q|
|
|
178
|
+
q.required true
|
|
179
|
+
q.validate(%r{^https?://}i)
|
|
180
|
+
q.messages[:valid?] = 'Please enter a valid URL'
|
|
181
|
+
end
|
|
182
|
+
{ url: url }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def ask_slack_config
|
|
186
|
+
channel = prompt.ask('Slack channel (e.g., #general):') do |q|
|
|
187
|
+
q.required true
|
|
188
|
+
end
|
|
189
|
+
{ channel: channel }
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def build_description(task, schedule_info, tools_config)
|
|
193
|
+
parts = [task]
|
|
194
|
+
|
|
195
|
+
# Add schedule information
|
|
196
|
+
case schedule_info[:type]
|
|
197
|
+
when :daily
|
|
198
|
+
parts << schedule_info[:description]
|
|
199
|
+
when :interval
|
|
200
|
+
parts << schedule_info[:description]
|
|
201
|
+
when :continuous
|
|
202
|
+
# "continuously" might already be implied in task
|
|
203
|
+
parts << 'continuously' unless task.downcase.include?('continuous')
|
|
204
|
+
when :manual
|
|
205
|
+
# Manual trigger doesn't need to be in description
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Add tool-specific details
|
|
209
|
+
parts << "and email me at #{tools_config[:email][:address]}" if tools_config[:email]
|
|
210
|
+
|
|
211
|
+
if tools_config[:spreadsheet] && !task.include?('http')
|
|
212
|
+
# Replace generic "spreadsheet" with specific URL if not already present
|
|
213
|
+
parts << "using spreadsheet at #{tools_config[:spreadsheet][:url]}"
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
parts << "and send results to #{tools_config[:slack][:channel]}" if tools_config[:slack]
|
|
217
|
+
|
|
218
|
+
parts.join(' ')
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def show_preview(description, schedule_info, tools_config)
|
|
222
|
+
puts
|
|
223
|
+
puts pastel.cyan('╭─ Preview ──────────────────────────────╮')
|
|
224
|
+
puts '│'
|
|
225
|
+
puts "│ #{pastel.bold('Task:')} #{description}"
|
|
226
|
+
|
|
227
|
+
if schedule_info[:type] == :manual
|
|
228
|
+
puts "│ #{pastel.bold('Mode:')} Manual trigger"
|
|
229
|
+
else
|
|
230
|
+
schedule_text = schedule_info[:description] || 'on demand'
|
|
231
|
+
puts "│ #{pastel.bold('Schedule:')} #{schedule_text}"
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
puts "│ #{pastel.bold('Cron:')} #{pastel.dim(schedule_info[:cron])}" if schedule_info[:cron]
|
|
235
|
+
|
|
236
|
+
puts "│ #{pastel.bold('Tools:')} #{tools_config[:tools].join(', ')}" if tools_config[:tools]&.any?
|
|
237
|
+
|
|
238
|
+
puts '│'
|
|
239
|
+
puts pastel.cyan('╰────────────────────────────────────────╯')
|
|
240
|
+
puts
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def confirm_creation?
|
|
244
|
+
puts
|
|
245
|
+
prompt.yes?('Create this agent?')
|
|
246
|
+
rescue TTY::Reader::InputInterrupt
|
|
247
|
+
false
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
# rubocop:enable Metrics/ClassLength, Metrics/AbcSize
|
|
251
|
+
end
|
|
252
|
+
end
|