ace-runtime 0.1.0

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.
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/cli"
4
+ require "ace/core"
5
+
6
+ module Ace
7
+ module Runtime
8
+ module CLI
9
+ module Commands
10
+ # The neutral callback passthrough (Fork Callback Rule). Resolves
11
+ # the configured runtime and delegates to runtime.send with the
12
+ # normative send-matrix semantics: ordered delivery on plain
13
+ # panes, single self-submitting prompt shapes on agent panes.
14
+ # Rejected input fails before any transport call.
15
+ class Send < Ace::Support::Cli::Command
16
+ include Ace::Support::Cli::Base
17
+
18
+ desc <<~DESC.strip
19
+ Send a submitted command, text chunks, or named keys to a pane via the resolved terminal runtime
20
+
21
+ Runtime resolution: --runtime > ACE_RUNTIME > configured runtime > auto-detection
22
+ (tmux wins when both environments are live). Rejected sends make no transport call.
23
+ DESC
24
+
25
+ example [
26
+ "--pane %1 --cmd 'bundle exec rake test'",
27
+ "--pane %1 --msg 'Reply with exactly: pong' --key Enter",
28
+ "--runtime herdr --pane w1:p1 --msg 'hello'",
29
+ "--pane %1 --key C-c"
30
+ ]
31
+
32
+ option :runtime, type: :string, desc: "Terminal runtime name (tmux, herdr); overrides ACE_RUNTIME, config, and detection"
33
+ option :pane, type: :string, required: true, desc: "Target pane handle as exposed by the runtime (opaque, adapter-owned)"
34
+ option :cmd, type: :string, aliases: %w[-c], desc: "Command text to submit once (declare before any --key)"
35
+ option :msg, type: :string, aliases: %w[-m], repeat: true, desc: "Literal text chunk to deliver without implicit submission"
36
+ option :key, type: :string, aliases: %w[-k], repeat: true, desc: "Named key to deliver (for example Enter, C-c)"
37
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
38
+
39
+ def call(**options)
40
+ command, items = build_request(options)
41
+ selector = Molecules::RuntimeSelector.new(config: Ace::Runtime.config)
42
+ runtime = selector.resolve(explicit: options[:runtime])
43
+ result = runtime.send(pane: options[:pane], command: command, items: items)
44
+
45
+ report(selector.selected_name, command, items, result) unless quiet?(options)
46
+ rescue Ace::Runtime::Error => e
47
+ raise Ace::Support::Cli::Error, e.message
48
+ end
49
+
50
+ private
51
+
52
+ # The callback CLI builds direct-API items as all supplied
53
+ # messages followed by all supplied keys; direct Ruby callers
54
+ # use send(items:) when they need arbitrary interleaving.
55
+ def build_request(options)
56
+ reject_leading_keys!
57
+
58
+ command = normalize_cmd(options[:cmd])
59
+ items = Array(options[:msg]).map { |text| {message: text} } +
60
+ Array(options[:key]).map { |name| {key: name} }
61
+
62
+ if command.nil? && items.empty?
63
+ raise Ace::Support::Cli::Error, "provide at least one of --cmd, --msg, or --key"
64
+ end
65
+
66
+ [command, items]
67
+ end
68
+
69
+ # Leading keys (--key Esc --cmd run) are a normative usage
70
+ # error: --cmd must be declared before every key.
71
+ def reject_leading_keys!
72
+ cmd_index = first_flag_index(%w[--cmd -c])
73
+ key_indices = flag_indices(%w[--key -k])
74
+ return if cmd_index.nil? || key_indices.empty? || key_indices.min > cmd_index
75
+
76
+ raise Ace::Support::Cli::Error,
77
+ "--key before --cmd is a usage error: --cmd must be declared before every key"
78
+ end
79
+
80
+ def flag_indices(flags)
81
+ Ace::Runtime::CLI.last_invocation_args.each_index.select do |index|
82
+ token = Ace::Runtime::CLI.last_invocation_args[index]
83
+ flags.any? { |flag| token == flag || token.start_with?("#{flag}=") }
84
+ end
85
+ end
86
+
87
+ def first_flag_index(flags)
88
+ list = flag_indices(flags)
89
+ list.empty? ? nil : list.min
90
+ end
91
+
92
+ def normalize_cmd(value)
93
+ text = value.to_s
94
+ return nil if text.strip.empty?
95
+
96
+ text
97
+ end
98
+
99
+ def report(runtime_name, command, items, result)
100
+ parts = []
101
+ parts << "command" if command
102
+ message_count = items.count { |item| item.key?(:message) }
103
+ key_count = items.count { |item| item.key?(:key) }
104
+ parts << pluralize(message_count, "message") if message_count.positive?
105
+ parts << pluralize(key_count, "key") if key_count.positive?
106
+
107
+ line = "Sent via #{runtime_name}: #{parts.join(' + ')}"
108
+ line += " (dropped trailing Enter for agent pane)" if result&.dropped_trailing_enter
109
+
110
+ puts line
111
+ end
112
+
113
+ def pluralize(count, noun)
114
+ "#{count} #{noun}#{count == 1 ? '' : 's'}"
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/cli"
4
+ require "ace/core"
5
+ require_relative "../runtime"
6
+ require_relative "cli/commands/send"
7
+
8
+ module Ace
9
+ module Runtime
10
+ # ace-support-cli based CLI registry for ace-runtime. The contract
11
+ # ships exactly one agent-facing command: the neutral send
12
+ # passthrough (Fork Callback Rule decision, 2026-09-27).
13
+ module CLI
14
+ extend Ace::Support::Cli::RegistryDsl
15
+
16
+ PROGRAM_NAME = "ace-runtime"
17
+
18
+ # Application commands with descriptions (for help output)
19
+ REGISTERED_COMMANDS = [
20
+ ["send", "Send a submitted command, text chunks, or named keys to a pane via the resolved terminal runtime"]
21
+ ].freeze
22
+
23
+ HELP_EXAMPLES = [
24
+ "ace-runtime send --pane %1 --cmd 'bundle exec rake test' # Submit a command",
25
+ "ace-runtime send --pane %1 --msg 'Reply with exactly: pong' --key Enter # Callback form: submits exactly once",
26
+ "ace-runtime send --runtime herdr --pane w1:p1 --msg 'hello' # Explicit runtime selection",
27
+ "ace-runtime send --pane %1 --key C-c # Keys-only send"
28
+ ].freeze
29
+
30
+ # Start the CLI
31
+ #
32
+ # @param args [Array<String>] Command-line arguments
33
+ # @return [Integer] Exit code (0 for success, non-zero for failure)
34
+ def self.start(args)
35
+ @last_invocation_args = args
36
+ Ace::Support::Cli::Runner.new(self).call(args: args)
37
+ end
38
+
39
+ # Raw arguments of the current invocation. The send command reads
40
+ # these for flag declaration order (--cmd before every --key);
41
+ # falls back to ARGV when the CLI was not started through #start.
42
+ def self.last_invocation_args
43
+ @last_invocation_args || ARGV
44
+ end
45
+
46
+ # Register commands
47
+ register "send", CLI::Commands::Send.new
48
+
49
+ # Register version command
50
+ version_cmd = Ace::Support::Cli::VersionCommand.build(
51
+ gem_name: "ace-runtime",
52
+ version: Ace::Runtime::VERSION
53
+ )
54
+ register "version", version_cmd
55
+ register "--version", version_cmd
56
+
57
+ # Register help command
58
+ help_cmd = Ace::Support::Cli::HelpCommand.build(
59
+ program_name: PROGRAM_NAME,
60
+ version: Ace::Runtime::VERSION,
61
+ commands: REGISTERED_COMMANDS,
62
+ examples: HELP_EXAMPLES
63
+ )
64
+ register "help", help_cmd
65
+ register "--help", help_cmd
66
+ register "-h", help_cmd
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Runtime
5
+ # Contract error hierarchy. Every runtime failure surfaces as one of
6
+ # these typed errors; adapters translate their native failures into
7
+ # them at the boundary.
8
+ class Error < StandardError; end
9
+
10
+ # The requested runtime name has no registered adapter (after the
11
+ # convention-based entrypoint load attempt). Fails closed and names
12
+ # the available runtimes.
13
+ class UnknownRuntimeError < Error
14
+ attr_reader :requested, :available
15
+
16
+ def initialize(requested:, available:)
17
+ @requested = requested
18
+ @available = Array(available).sort.freeze
19
+ super("unknown runtime '#{requested}' (available: #{@available.join(', ')})")
20
+ end
21
+ end
22
+
23
+ # The runtime is known but not usable right now (server unreachable,
24
+ # no live session/workspace). Never silently downgraded to another
25
+ # runtime or to a headless mode.
26
+ class RuntimeUnavailableError < Error; end
27
+
28
+ # A window/pane target does not exist. Target values are opaque,
29
+ # adapter-owned handles; the contract never predicts or formats them.
30
+ class TargetNotFoundError < Error; end
31
+
32
+ # ensure_window found an existing window with the same normalized
33
+ # name scoped to the current session/workspace but an incompatible
34
+ # root or preset.
35
+ class WindowConflictError < Error; end
36
+
37
+ # Pre-send rejection (invalid send shape or an agent pane blocking
38
+ # the delivery). Raised before any transport call; terminal.
39
+ class SendRejectedError < Error; end
40
+
41
+ # The submission was accepted but the target did not start
42
+ # processing it. The outcome is uncertain: callers must not
43
+ # auto-resend the same content.
44
+ class SendStalledError < Error; end
45
+
46
+ # A wait exceeded its deadline. Timeout units at the contract level
47
+ # are SECONDS; adapters convert to native units internally.
48
+ class WaitTimeoutError < Error
49
+ attr_reader :condition, :target, :timeout
50
+
51
+ def initialize(condition:, timeout:, target: nil)
52
+ @condition = condition
53
+ @target = target
54
+ @timeout = timeout
55
+ target_part = target ? " on '#{target}'" : ""
56
+ super("timed out waiting for #{condition}#{target_part} after #{timeout}s")
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Runtime
5
+ module Molecules
6
+ # Runtime selection precedence: explicit name > ACE_RUNTIME >
7
+ # configured runtime > side-effect-free environment detection.
8
+ # Resolution happens only after selection. An explicitly selected
9
+ # runtime that turns out unknown or unavailable fails closed via
10
+ # the registry — never headless, never a silent switch to another
11
+ # runtime. Only the assign auto launch mode selects headless
12
+ # outside any runtime; that decision lives above this selector.
13
+ class RuntimeSelector
14
+ ENV_RUNTIME_KEY = "ACE_RUNTIME"
15
+
16
+ attr_reader :selected_name
17
+
18
+ def initialize(config: nil, registry: nil, env: ENV)
19
+ @config = config
20
+ @registry = registry
21
+ @env = env
22
+ @selected_name = nil
23
+ end
24
+
25
+ def resolve(explicit: nil)
26
+ name = select_name(explicit: explicit)
27
+
28
+ if name.nil? || name.empty?
29
+ raise RuntimeUnavailableError,
30
+ "no terminal runtime selected: pass --runtime, set #{ENV_RUNTIME_KEY}, " \
31
+ "configure runtime under the ace-runtime config namespace, or run inside tmux/herdr"
32
+ end
33
+
34
+ @selected_name = name
35
+ registry.resolve(name)
36
+ end
37
+
38
+ private
39
+
40
+ attr_reader :config, :env
41
+
42
+ def registry
43
+ @registry || Ace::Runtime.registry
44
+ end
45
+
46
+ def select_name(explicit:)
47
+ name = explicit.to_s.strip
48
+ name = env_value(ENV_RUNTIME_KEY) if name.empty?
49
+ name = configured_runtime if name.empty?
50
+ name = detected_name if name.empty?
51
+
52
+ name
53
+ end
54
+
55
+ def env_value(key)
56
+ env[key].to_s.strip
57
+ end
58
+
59
+ def configured_runtime
60
+ return "" unless config.is_a?(Hash)
61
+
62
+ value = config.key?("runtime") ? config["runtime"] : config[:runtime]
63
+ value.to_s.strip
64
+ end
65
+
66
+ def detected_name
67
+ detected = Atoms::Detector.detect(env: env)
68
+ detected ? detected.to_s : ""
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Runtime
5
+ # Duck-typed adapter registry (ace-hitl Providers pattern). Runtime
6
+ # adapters register a factory under a simple name; resolution is
7
+ # fail-closed: an unknown name raises UnknownRuntimeError carrying
8
+ # the sorted available list. There is no base class — an adapter is
9
+ # whatever object implements the published intent operations.
10
+ class Registry
11
+ # Only identifier-shaped names may trigger the convention-based
12
+ # entrypoint load, so a hostile runtime name can never traverse
13
+ # the Ruby load path.
14
+ NAME_PATTERN = /\A[a-z][a-z0-9_]*\z/
15
+
16
+ ADAPTER_ENTRYPOINT_FORMAT = "ace/runtime/adapters/%s"
17
+
18
+ def initialize
19
+ @factories = {}
20
+ end
21
+
22
+ def register(name, factory = nil, &block)
23
+ key = name.to_s
24
+ callable = factory || block
25
+ raise ArgumentError, "adapter factory for '#{key}' must respond to #call" unless callable.respond_to?(:call)
26
+
27
+ @factories[key] = callable
28
+ nil
29
+ end
30
+
31
+ def registered?(name)
32
+ @factories.key?(name.to_s)
33
+ end
34
+
35
+ def available
36
+ @factories.keys.sort
37
+ end
38
+
39
+ def resolve(name)
40
+ key = name.to_s
41
+ load_adapter_entrypoint(key) unless @factories.key?(key)
42
+
43
+ factory = @factories[key]
44
+ raise UnknownRuntimeError.new(requested: key, available: available) unless factory
45
+
46
+ factory.call
47
+ end
48
+
49
+ private
50
+
51
+ # Convention: adapter packages ship a lib/ace/runtime/adapters/<name>.rb
52
+ # entrypoint that calls Ace::Runtime.register. It is attempted
53
+ # lazily on first resolve so the contract gem keeps no dependency
54
+ # on either adapter package. Only a missing entrypoint counts as
55
+ # "not installed"; a LoadError raised INSIDE an installed
56
+ # entrypoint (for example its own missing dependency) propagates
57
+ # so a broken adapter is never misreported as unknown.
58
+ def load_adapter_entrypoint(name)
59
+ return unless name.match?(NAME_PATTERN)
60
+
61
+ entrypoint = format(ADAPTER_ENTRYPOINT_FORMAT, name)
62
+ require entrypoint
63
+ rescue LoadError => e
64
+ raise unless e.path == entrypoint
65
+ end
66
+ end
67
+ end
68
+ end