Class: Ukiryu::Tool

Inherits:
Object
  • Object
show all
Includes:
CommandBuilder, CommandResolution, ExecutableDiscovery, VersionDetection
Defined in:
lib/ukiryu/tool.rb,
lib/ukiryu/tool/loader.rb,
lib/ukiryu/tool/version_detection.rb,
lib/ukiryu/tool/command_resolution.rb,
lib/ukiryu/tool/executable_discovery.rb

Overview

Tool wrapper class for external command-line tools

Provides a Ruby interface to external CLI tools defined in YAML profiles.

## Usage

### Traditional API (backward compatible)

tool = Ukiryu::Tool.get(:imagemagick)
tool.execute(:convert, inputs: ["image.png"], resize: "50%")

### New OOP API (recommended)

# Lazy autoload - creates Ukiryu::Tools::Imagemagick class on first access
Ukiryu::Tools::Imagemagick.new.tap do |tool|
  convert_options = tool.options_for(:convert)
  convert_options.set(inputs: ["image.png"], resize: "50%")
  convert_options.output = "output.jpg"
  convert_options.run
end

Defined Under Namespace

Modules: CommandResolution, ExecutableDiscovery, Loader, VersionDetection

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from ExecutableDiscovery

#alias?, #discovery_description, #find_executable, #path_found?

Methods included from CommandResolution

#execute, #execute_action, #find_action_with_parent, #find_command_profile, #resolve_action_path

Methods included from VersionDetection

#check_version_compatibility, #detect_version, #detect_version_with_detection_methods, #probe_flag, #profile_version_requirement

Methods included from CommandBuilder

#build_args, #build_env_vars, #detect_delimiter, #format_arg, #format_flag, #format_option

Constructor Details

#initialize(profile, options = {}) ⇒ Tool

Create a new Tool instance

Parameters:

  • profile (Models::ToolDefinition)

    the tool definition model

  • options (Hash) (defaults to: {})

    initialization options

Options Hash (options):

Raises:



818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
# File 'lib/ukiryu/tool.rb', line 818

def initialize(profile, options = {})
  @profile = profile
  @options = options
  @definition_source = options[:definition_source]
  runtime = Ukiryu::Runtime.instance

  # Allow override via options for testing
  @platform = options[:platform]&.to_sym || runtime.platform
  @shell = options[:shell]&.to_sym || runtime.shell
  @version = options[:version]

  # Find compatible profile
  @command_profile = find_command_profile
  raise Ukiryu::Errors::ProfileNotFoundError, "No compatible profile for #{name}" unless @command_profile

  # Find executable
  @executable = find_executable
end

Instance Attribute Details

#definition_sourceDefinition::Source? (readonly)

Get the definition source if loaded from non-register source

Returns:



873
874
875
# File 'lib/ukiryu/tool.rb', line 873

def definition_source
  @definition_source
end

#executableString (readonly)

Get the executable path

Returns:

  • (String)

    the executable path



892
893
894
# File 'lib/ukiryu/tool.rb', line 892

def executable
  @executable
end

#executable_infoModels::ExecutableInfo? (readonly)

Get the executable discovery information

Returns:



897
898
899
# File 'lib/ukiryu/tool.rb', line 897

def executable_info
  @executable_info
end

#profileHash (readonly)

Get the raw profile data

Returns:

  • (Hash)

    the tool profile



840
841
842
# File 'lib/ukiryu/tool.rb', line 840

def profile
  @profile
end

Class Method Details

.bundled_definition_search_pathsArray<String>

Get bundled definition search paths

Returns:

  • (Array<String>)

    list of search paths



734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
# File 'lib/ukiryu/tool.rb', line 734

def bundled_definition_search_paths
  platform = Ukiryu::Platform.detect

  paths = case platform
          when :macos, :linux
            [
              '/usr/share/ukiryu',
              '/usr/local/share/ukiryu',
              '/opt/homebrew/share/ukiryu'
            ]
          when :windows
            [
              File.expand_path('C:/Program Files/Ukiryu'),
              File.expand_path('C:/Program Files (x86)/Ukiryu')
            ]
          else
            []
          end

  # Add user-local path
  paths << File.expand_path('~/.local/share/ukiryu')

  paths
end

.clear_cacheObject

Clear the tool cache



639
640
641
# File 'lib/ukiryu/tool.rb', line 639

def clear_cache
  ToolCache.clear
end

.clear_definition_cacheObject

Clear the definition cache only



646
647
648
# File 'lib/ukiryu/tool.rb', line 646

def clear_definition_cache
  ToolCache.clear_definition_cache
end

.configure(options = {}) ⇒ Object

Configure default options

Parameters:

  • options (Hash) (defaults to: {})

    default options



671
672
673
674
# File 'lib/ukiryu/tool.rb', line 671

def configure(options = {})
  @default_options ||= {}
  @default_options.merge!(options)
end

.convert_actions_to_array(actions_data) ⇒ Array<Hash>

Convert actions hash to array format

Parameters:

  • actions_data (Hash, Array)

    actions hash with command names as keys, or array of command definitions

Returns:

  • (Array<Hash>)

    array of command definitions



503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
# File 'lib/ukiryu/tool.rb', line 503

def convert_actions_to_array(actions_data)
  return [] if actions_data.nil? || actions_data.empty?

  # Handle both Hash (old format) and Array (new format from Interface)
  if actions_data.is_a?(Hash)
    actions_data.map do |command_name, command_def|
      # command_def is already a hash, just add the name if not present
      command_def = command_def.to_h
      command_def['name'] ||= command_name.to_s
      command_def
    end
  else
    # Array format - convert to hash and ensure name is set
    actions_data.map do |command_def|
      command_def = command_def.to_h
      # Flatten signature if present (interface format)
      if command_def[:signature] || command_def['signature']
        signature = command_def[:signature] || command_def['signature']
        # Merge signature contents into command_def, excluding the signature key itself
        signature.each do |key, value|
          # Handle nested structure: signature[:inputs] contains inputs/options/flags
          if [:inputs, 'inputs'].include?(key)
            # If value is a hash, merge its contents directly
            if value.is_a?(Hash)
              value.each do |nested_key, nested_value|
                # Rename 'inputs' to 'arguments' for CommandDefinition compatibility
                target_key = case nested_key.to_s
                             when 'inputs' then 'arguments'
                             else nested_key.to_s
                             end
                command_def[target_key.to_sym] = nested_value unless [:signature,
                                                                      'signature'].include?(nested_key)
              end
            else
              command_def[key] = value
            end
          else
            command_def[key] = value unless [:signature, 'signature'].include?(key)
          end
        end
        command_def.delete(:signature)
        command_def.delete('signature')
      end
      command_def
    end
  end
end

.convert_hash_to_command_definition(cmd_hash) ⇒ CommandDefinition

Convert hash to CommandDefinition object

Parameters:

  • cmd_hash (Hash)

    command definition hash

Returns:

  • (CommandDefinition)

    command definition object



460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
# File 'lib/ukiryu/tool.rb', line 460

def convert_hash_to_command_definition(cmd_hash)
  require_relative 'models/command_definition'

  # Create CommandDefinition from hash
  post_options_data = cmd_hash['post_options'] || cmd_hash[:post_options]

  # Debug logging for Ruby 3.4+ CI
  if ENV['UKIRYU_DEBUG_EXECUTABLE']
    warn "[UKIRYU DEBUG build_command_definition] cmd.name: #{cmd_hash['name'] || cmd_hash[:name]}"
    warn "[UKIRYU DEBUG build_command_definition] post_options_data: #{post_options_data.inspect}"
    warn "[UKIRYU DEBUG build_command_definition] post_options_data.class: #{post_options_data.class}" if post_options_data
    if post_options_data.is_a?(Array)
      post_options_data.first(2).each do |opt|
        warn "[UKIRYU DEBUG build_command_definition] post_option: #{opt.inspect}"
      end
    end
  end

  Models::CommandDefinition.new(
    name: cmd_hash['name'] || cmd_hash[:name],
    description: cmd_hash['description'] || cmd_hash[:description],
    usage: cmd_hash['usage'] || cmd_hash[:usage],
    subcommand: cmd_hash['subcommand'] || cmd_hash[:subcommand],
    belongs_to: cmd_hash['belongs_to'] || cmd_hash[:belongs_to],
    cli_flag: cmd_hash['cli_flag'] || cmd_hash[:cli_flag],
    standalone_executable: cmd_hash['standalone_executable'] || cmd_hash[:standalone_executable] || false,
    aliases: cmd_hash['aliases'] || cmd_hash[:aliases] || [],
    use_env_vars: cmd_hash['use_env_vars'] || cmd_hash[:use_env_vars] || [],
    implements: cmd_hash['implements'] || cmd_hash[:implements] || [],
    options: cmd_hash['options'] || cmd_hash[:options],
    flags: cmd_hash['flags'] || cmd_hash[:flags],
    arguments: cmd_hash['arguments'] || cmd_hash[:arguments],
    post_options: post_options_data,
    env_vars: cmd_hash['env_vars'] || cmd_hash[:env_vars],
    exit_codes: cmd_hash['exit_codes'] || cmd_hash[:exit_codes]
  )
end

.convert_profile_to_hash(profile, actions) ⇒ Hash

Convert ExecutionProfile to hash format for ToolDefinition

Parameters:

Returns:

  • (Hash)

    profile hash



347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
# File 'lib/ukiryu/tool.rb', line 347

def convert_profile_to_hash(profile, actions)
  # Handle both Hash and ExecutionProfile objects
  actions_hash = actions || {}
  commands_array = convert_actions_to_array(actions_hash)
  if profile.is_a?(Hash)
    # Use the actions parameter (interface.actions), not profile[:actions]
    # Convert actions hash to array format expected by ToolDefinition
    {
      'name' => profile[:name] || profile['name'],
      'display_name' => profile[:display_name] || profile['display_name'],
      'platforms' => profile[:platforms] || profile['platforms'],
      'shells' => profile[:shells] || profile['shells'],
      'option_style' => profile[:option_style] || profile['option_style'],
      'executable_name' => profile[:executable_name] || profile['executable_name'],
      'commands' => commands_array
    }
  else
    {
      'name' => profile.name,
      'display_name' => profile.display_name,
      'platforms' => profile.platforms,
      'shells' => profile.shells,
      'option_style' => profile.option_style,
      'executable_name' => profile.executable_name,
      'commands' => commands_array
    }
  end
end

.convert_profile_to_platform_profile(profile, actions) ⇒ PlatformProfile

Convert ExecutionProfile to PlatformProfile object for ToolDefinition

Parameters:

Returns:

  • (PlatformProfile)

    platform profile object



381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
# File 'lib/ukiryu/tool.rb', line 381

def convert_profile_to_platform_profile(profile, actions)
  require_relative 'models/platform_profile'
  require_relative 'models/command_definition'

  # Handle both Hash and ExecutionProfile objects
  if profile.is_a?(Hash)
    profile_data = profile
    profile_commands = profile[:commands] || profile['commands'] || []
  else
    profile_data = {
      name: profile.name,
      display_name: profile.display_name,
      platforms: profile.platforms,
      shells: profile.shells,
      option_style: profile.option_style
    }
    profile_commands = profile.commands || []
  end

  # Convert interface actions to command definitions hash (by name)
  interface_commands_hash = {}
  convert_actions_to_array(actions || []).each do |cmd|
    cmd_name = cmd[:name] || cmd['name']
    interface_commands_hash[cmd_name] = cmd
  end

  # Build command definitions by merging interface and profile data
  # If profile has commands, merge them with interface actions
  # If profile has no commands, use interface actions directly
  command_definitions = if profile_commands.nil? || profile_commands.empty?
                          # No profile commands - use interface actions directly
                          interface_commands_hash.map do |_cmd_name, cmd_hash|
                            convert_hash_to_command_definition(cmd_hash)
                          end
                        else
                          # Profile has commands - merge with interface actions
                          profile_commands.map do |cmd_hash|
                            # Command name may be specified as 'name' or 'subcommand' field
                            cmd_name = cmd_hash[:name] || cmd_hash['name'] || cmd_hash[:subcommand] || cmd_hash['subcommand']
                            # Merge profile command data with interface action data
                            interface_cmd = interface_commands_hash[cmd_name]
                            merged_cmd_hash = if interface_cmd
                                                # Deep merge: profile data takes precedence
                                                deep_merge_hashes(interface_cmd, cmd_hash)
                                              else
                                                cmd_hash
                                              end
                            convert_hash_to_command_definition(merged_cmd_hash)
                          end
                        end

  # Create PlatformProfile
  Models::PlatformProfile.new(
    **profile_data,
    commands: command_definitions
  )
end

.convert_to_tool_definition(tool_name, interface, impl_version, implementation_name, detected_version, options = {}) ⇒ ToolDefinition

Convert ImplementationVersion to ToolDefinition for compatibility

Parameters:

  • tool_name (String)

    tool name

  • interface (Models::Interface)

    interface

  • impl_version (Models::ImplementationVersion)

    implementation version

  • implementation_name (String)

    implementation name

  • options (Hash) (defaults to: {})

    options

Returns:

  • (ToolDefinition)

    converted tool definition



272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
# File 'lib/ukiryu/tool.rb', line 272

def convert_to_tool_definition(tool_name, interface, impl_version, implementation_name, detected_version,
                               options = {})
  require_relative 'models/tool_definition'
  require_relative 'models/platform_profile'

  # Select compatible execution profile
  profile = impl_version.compatible_profile(
    platform: options[:platform] || Platform.detect,
    shell: options[:shell] || Shell.detect
  )

  return nil unless profile

  # Build ToolDefinition from execution profile
  # Note: implements must be an array for the v2 model
  # Only append implementation name for non-default implementations
  if implementation_name && implementation_name != 'default'
    "#{tool_name}_#{implementation_name}"
  else
    tool_name
  end
  # Use detected version if available, otherwise fall back to YAML version
  detected_version || impl_version.version
  # Build ToolDefinition from execution profile
  # Note: implements must be an array for the v2 model
  # Only append implementation name for non-default implementations
  specific_tool_name = if implementation_name && implementation_name != 'default'
                         "#{tool_name}_#{implementation_name}"
                       else
                         tool_name
                       end
  # Use detected version if available, otherwise fall back to YAML version
  version = detected_version || impl_version.version
  tool_def = Models::ToolDefinition.new(
    name: specific_tool_name,
    version: version,
    display_name: impl_version.display_name || "#{interface.name} #{implementation_name} #{version}",
    implements: Array(interface.name), # v2: expects array
    profiles: [convert_profile_to_platform_profile(profile, interface.actions)],
    version_detection: impl_version.version_detection, # Extract from implementation
    aliases: impl_version.aliases || []
  )

  # Resolve profile inheritance after creation
  # Debug logging for Ruby 3.4+ CI
  if ENV['UKIRYU_DEBUG_EXECUTABLE']
    warn '[UKIRYU DEBUG] Before resolve_inheritance!'
    warn "[UKIRYU DEBUG] tool_def.profiles.size: #{tool_def.profiles.size}"
    tool_def.profiles.each do |prof|
      prof_name = prof.name if prof.respond_to?(:name)
      prof_commands = prof.commands if prof.respond_to?(:commands)
      warn "[UKIRYU DEBUG] Profile: #{prof_name}, commands: #{prof_commands&.size || 0}"
    end
  end

  tool_def.resolve_inheritance!

  # Debug logging for Ruby 3.4+ CI
  if ENV['UKIRYU_DEBUG_EXECUTABLE']
    warn '[UKIRYU DEBUG] After resolve_inheritance!'
    tool_def.profiles.each do |prof|
      prof_name = prof.name if prof.respond_to?(:name)
      prof_commands = prof.commands if prof.respond_to?(:commands)
      warn "[UKIRYU DEBUG] Profile: #{prof_name}, commands: #{prof_commands&.size || 0}"
    end
  end

  tool_def
end

.deep_merge_hashes(base, override) ⇒ Hash

Deep merge two hashes (second hash takes precedence)

Parameters:

  • base (Hash)

    base hash

  • override (Hash)

    override hash (takes precedence)

Returns:

  • (Hash)

    merged hash



444
445
446
447
448
449
450
451
452
453
454
# File 'lib/ukiryu/tool.rb', line 444

def deep_merge_hashes(base, override)
  base.merge(override) do |_key, old_val, new_val|
    if old_val.is_a?(Hash) && new_val.is_a?(Hash)
      deep_merge_hashes(old_val, new_val)
    elsif new_val.nil?
      old_val
    else
      new_val
    end
  end
end

.detect_implementation_and_version(index, tool_name, options = {}) ⇒ Hash?

Detect implementation and version from ImplementationIndex

Parameters:

  • index (Models::ImplementationIndex)

    the implementation index

  • tool_name (String)

    the tool name for executable lookup

  • options (Hash) (defaults to: {})

    options including platform and shell

Returns:

  • (Hash, nil)

    hash with :implementation_name, :version, :file or nil



63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
# File 'lib/ukiryu/tool.rb', line 63

def detect_implementation_and_version(index, tool_name, options = {})
  # Try each implementation in order
  index.implementations.each do |impl|
    # Run detection command
    detection = impl[:detection] || impl['detection']
    detection_result = run_detection_command(detection, tool_name, options)
    next unless detection_result

    # Extract version using pattern
    # If pattern has no capture group, detection_result is returned but we should use default version
    pattern = detection[:pattern] || detection['pattern']
    version = extract_version_from_pattern(detection_result, pattern)

    # If detection succeeded but no version was extracted, check if pattern matched
    # If pattern was just a presence check (no capture group), use nil version with default spec
    if version.nil?
      # Check if pattern matched at all (presence check)
      has_pattern = detection_result.match?(Regexp.new(pattern)) if pattern
      # If pattern didn't match, skip this implementation
      next unless has_pattern
      # Pattern matched but no version - will use default version spec below
    end

    # Resolve versionian scheme
    require_relative 'version_scheme_resolver'
    version_scheme = impl[:version_scheme] || impl['version_scheme']
    scheme = VersionSchemeResolver.resolve(version_scheme)

    # Find matching version spec
    # If version is nil (presence check only), use implementation default
    if version.nil?
      versions = impl[:versions] || impl['versions']
      # Prefer implementation-level default, then version-level default, then last version
      impl_default = impl[:default] || impl['default']
      version_spec = if impl_default
                       # Find version spec matching the implementation default
                       versions.find do |v|
                         v[:file] == impl_default || v['file'] == impl_default
                       end || versions.last
                     else
                       versions.find { |v| v[:default] || v['default'] } || versions.last
                     end
      return {
        implementation_name: impl[:name] || impl['name'],
        version: nil,
        file: version_spec[:file] || version_spec['file'] || impl_default
      }
    end

    # Find matching version spec for detected version
    versions = impl[:versions] || impl['versions']
    version_spec = find_matching_version_spec(versions, version, scheme)

    if version_spec
      return {
        implementation_name: impl[:name] || impl['name'],
        version: version,
        file: version_spec[:file] || version_spec['file']
      }
    end
  end

  # If no implementation matched, use the first one's default
  return nil if index.implementations.empty?

  impl = index.implementations.first
  versions = impl[:versions] || impl['versions']
  # Prefer implementation-level default, then version-level default, then last version
  impl_default = impl[:default] || impl['default']
  default_spec = if impl_default
                   # Find version spec matching the implementation default
                   versions.find { |v| v[:file] == impl_default || v['file'] == impl_default } || versions.last
                 else
                   versions.find { |v| v[:default] || v['default'] } || versions.last
                 end
  {
    implementation_name: impl[:name] || impl['name'],
    version: nil,
    file: default_spec[:file] || default_spec['file'] || impl_default
  }
end

.extract_definition(tool_name, options = {}) ⇒ Hash

Extract tool definition from an installed CLI tool

Attempts to extract a tool definition by:

  1. Trying the tool’s native ‘–ukiryu-definition` flag

  2. Parsing the tool’s ‘–help` output as a fallback

Examples:

Extract definition from git

result = Tool.extract_definition(:git)
if result[:success]
  puts result[:yaml]
end

Extract and write to file

result = Tool.extract_definition(:git, output: './git.yaml')

Parameters:

  • tool_name (String, Symbol)

    the tool name to extract

  • options (Hash) (defaults to: {})

    extraction options

Options Hash (options):

  • :output (String)

    optional output file path

  • :method (Symbol)

    specific method (:native, :help, :auto)

  • :verbose (Boolean)

    enable verbose output

Returns:

  • (Hash)

    result with :success, :yaml, :method, :error keys



780
781
782
783
784
785
786
787
788
789
790
791
792
# File 'lib/ukiryu/tool.rb', line 780

def extract_definition(tool_name, options = {})
  result = Ukiryu::Extractors::Extractor.extract(tool_name, options)

  # Write to output file if specified
  output = options.delete(:output)
  if output && result[:success]
    require 'fileutils'
    FileUtils.mkdir_p(File.dirname(output))
    File.write(output, result[:yaml])
  end

  result
end

.extract_version_from_pattern(output, pattern) ⇒ String?

Extract version from command output using pattern

Parameters:

  • output (String)

    command output

  • pattern (String)

    regex pattern

Returns:

  • (String, nil)

    extracted version or nil



208
209
210
211
212
213
214
215
216
217
218
# File 'lib/ukiryu/tool.rb', line 208

def extract_version_from_pattern(output, pattern)
  return nil unless output && pattern

  # Scrub output to handle invalid UTF-8 byte sequences
  scrubbed_output = output.scrub('')
  match = scrubbed_output.match(Regexp.new(pattern))
  return nil unless match

  # Return capture group if present, otherwise nil (presence check)
  match[1]
end

.find(name, options = {}) ⇒ Tool?

Find a tool by name, returning nil if not found.

This is a non-raising alternative to get for cases where tool absence is expected and should be handled gracefully.

Examples:

tool = Ukiryu::Tool.find(:imagemagick)
if tool
  tool.execute(:convert, inputs: ["image.png"])
else
  puts "ImageMagick not available"
end

Parameters:

  • name (String, Symbol)

    the tool name

  • options (Hash) (defaults to: {})

    initialization options

Options Hash (options):

  • :register_path (String)

    path to tool profiles

  • :platform (Symbol)

    platform to use

  • :shell (Symbol)

    shell to use

Returns:

  • (Tool, nil)

    the tool instance or nil if not found



593
594
595
596
597
# File 'lib/ukiryu/tool.rb', line 593

def find(name, options = {})
  get(name, options)
rescue Ukiryu::Errors::ToolNotFoundError
  nil
end

.find_all(tool_name, options = {}) ⇒ Array<Models::ExecutableInfo>

Find all instances of a tool in PATH and aliases

This is an explicit operation - user must ask for it. Returns an array of ExecutableInfo for all matches found.

Parameters:

  • tool_name (String, Symbol)

    the tool to find

  • options (Hash) (defaults to: {})

    initialization options

Returns:



624
625
626
# File 'lib/ukiryu/tool.rb', line 624

def find_all(tool_name, options = {})
  ToolFinder.find_all(tool_name, options)
end

.find_by(identifier, options = {}) ⇒ Tool?

Find a tool by name, alias, or interface

Searches for a tool that matches the given identifier by:

  1. Exact name match (fastest)

  2. Interface match via ToolIndex (O(1) lookup)

  3. Alias match via ToolIndex (O(1) lookup)

  4. Returns the first tool that is available on the current platform

Debug mode: Set UKIRYU_DEBUG=1 or UKIRYU_DEBUG=true to enable structured debug output

Parameters:

  • identifier (String, Symbol)

    the tool name, interface, or alias

  • options (Hash) (defaults to: {})

    initialization options

Returns:

  • (Tool, nil)

    the tool instance or nil if not found



612
613
614
# File 'lib/ukiryu/tool.rb', line 612

def find_by(identifier, options = {})
  ToolFinder.find_by(identifier, options)
end

.find_matching_version_spec(versions, detected_version, scheme) ⇒ Hash?

Find matching version spec using versionian

Parameters:

  • versions (Array<Hash>)

    version specs

  • detected_version (String)

    detected version

  • scheme (Versionian::VersionScheme)

    versionian scheme

Returns:

  • (Hash, nil)

    matching version spec or nil



226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
# File 'lib/ukiryu/tool.rb', line 226

def find_matching_version_spec(versions, detected_version, scheme)
  require 'versionian'

  versions.each do |version_spec|
    range_type = if version_spec[:equals]
                   :equals
                 elsif version_spec[:before]
                   :before
                 elsif version_spec[:after]
                   :after
                 else
                   version_spec[:between] ? :between : nil
                 end

    next unless range_type

    range = case range_type
            when :equals
              boundary = version_spec[:equals] || version_spec['equals']
              Versionian::VersionRange.new(:equals, scheme, version: boundary)
            when :before
              boundary = version_spec[:before] || version_spec['before']
              Versionian::VersionRange.new(:before, scheme, version: boundary)
            when :after
              boundary = version_spec[:after] || version_spec['after']
              Versionian::VersionRange.new(:after, scheme, version: boundary)
            when :between
              between = version_spec[:between] || version_spec['between']
              from = between[:from] || between['from']
              to = between[:to] || between['to']
              Versionian::VersionRange.new(:between, scheme, from: from, to: to)
            end

    return version_spec if range&.matches?(detected_version)
  end
  nil
end

.from_bundled(tool_name, options = {}) ⇒ Tool?

Load a tool from bundled system locations

Searches standard system locations for tool definitions:

  • /usr/share/ukiryu/

  • /usr/local/share/ukiryu/

  • /opt/homebrew/share/ukiryu/

  • C:\Program Files\Ukiryu\

Parameters:

  • tool_name (String, Symbol)

    the tool name

  • options (Hash) (defaults to: {})

    initialization options

Returns:

  • (Tool, nil)

    the tool instance or nil if not found



716
717
718
719
720
721
722
723
724
725
726
727
728
729
# File 'lib/ukiryu/tool.rb', line 716

def from_bundled(tool_name, options = {})
  search_paths = bundled_definition_search_paths

  search_paths.each do |base_path|
    Dir.glob(File.join(base_path, tool_name.to_s, '*.yaml')).each do |file|
      return load(file, options)
    rescue Ukiryu::Errors::DefinitionLoadError, Ukiryu::Errors::DefinitionNotFoundError
      # Try next file
      next
    end
  end

  nil
end

.from_definition(yaml_string, options = {}) ⇒ Tool

Alias for load_from_string - load from YAML string

Parameters:

  • yaml_string (String)

    YAML content

  • options (Hash) (defaults to: {})

    initialization options

Returns:

  • (Tool)

    the tool instance



664
665
666
# File 'lib/ukiryu/tool.rb', line 664

def from_definition(yaml_string, options = {})
  load_from_string(yaml_string, options)
end

.from_file(file_path, options = {}) ⇒ Tool

Alias for load - load from file path

Parameters:

  • file_path (String)

    path to the YAML file

  • options (Hash) (defaults to: {})

    initialization options

Returns:

  • (Tool)

    the tool instance



655
656
657
# File 'lib/ukiryu/tool.rb', line 655

def from_file(file_path, options = {})
  load(file_path, options)
end

.get(name, options = {}) ⇒ Tool

Get a tool by name using the new ImplementationIndex architecture

Parameters:

  • name (String)

    the tool name

  • options (Hash) (defaults to: {})

    initialization options

Options Hash (options):

  • :register_path (String)

    path to tool profiles

  • :platform (Symbol)

    platform to use

  • :shell (Symbol)

    shell to use

Returns:

  • (Tool)

    the tool instance

Raises:



560
561
562
563
564
565
566
567
568
569
570
571
572
# File 'lib/ukiryu/tool.rb', line 560

def get(name, options = {})
  # Check cache first
  cache_key = cache_key_for(name, options)
  cached = tools_cache[cache_key]
  return cached if cached

  # Load using ImplementationIndex architecture
  tool = load_with_implementation_index(name, options)
  raise Ukiryu::Errors::ToolNotFoundError, "Tool not found: #{name}" unless tool

  tools_cache[cache_key] = tool
  tool
end

.get_class(tool_name) ⇒ Class

Get the tool-specific class (new OOP API)

Parameters:

  • tool_name (Symbol, String)

    the tool name

Returns:

  • (Class)

    the tool class (e.g., Ukiryu::Tools::Imagemagick)



632
633
634
# File 'lib/ukiryu/tool.rb', line 632

def get_class(tool_name)
  ToolFinder.get_class(tool_name)
end

.load(file_path, options = {}) ⇒ Tool

Load a tool definition from a file path

Parameters:

  • file_path (String)

    path to the YAML file

  • options (Hash) (defaults to: {})

    initialization options

Options Hash (options):

  • :validation (Symbol)

    validation mode (:strict, :lenient, :none)

  • :version_check (Symbol)

    version check mode (:strict, :lenient, :probe)

Returns:

  • (Tool)

    the tool instance

Raises:

  • (DefinitionLoadError)

    if file cannot be loaded or validation fails



684
685
686
687
688
# File 'lib/ukiryu/tool.rb', line 684

def load(file_path, options = {})
  source = Ukiryu::Definition::Sources::FileSource.new(file_path)
  profile = Ukiryu::Definition::Loader.load_from_source(source, options)
  new(profile, options.merge(definition_source: source))
end

.load_from_string(yaml_string, options = {}) ⇒ Tool

Load a tool definition from a YAML string

Parameters:

  • yaml_string (String)

    YAML content

  • options (Hash) (defaults to: {})

    initialization options

Options Hash (options):

  • :file_path (String)

    optional file path for error messages

  • :validation (Symbol)

    validation mode (:strict, :lenient, :none)

  • :version_check (Symbol)

    version check mode (:strict, :lenient, :probe)

Returns:

  • (Tool)

    the tool instance

Raises:

  • (DefinitionLoadError)

    if YAML cannot be parsed or validation fails



699
700
701
702
703
# File 'lib/ukiryu/tool.rb', line 699

def load_from_string(yaml_string, options = {})
  source = Ukiryu::Definition::Sources::StringSource.new(yaml_string)
  profile = Ukiryu::Definition::Loader.load_from_source(source, options)
  new(profile, options.merge(definition_source: source))
end

.load_with_implementation_index(name, options = {}) ⇒ Tool?

Try loading a tool using the new ImplementationIndex architecture Delegates to Tool::Loader module

Parameters:

  • name (String, Symbol)

    the tool name

  • options (Hash) (defaults to: {})

    loading options

Returns:

  • (Tool, nil)

    the tool instance or nil if not using new architecture



53
54
55
# File 'lib/ukiryu/tool.rb', line 53

def load_with_implementation_index(name, options = {})
  Loader.load_with_implementation_index(name, options)
end

.profile_is_modern?(profile_version, version_detection) ⇒ Boolean?

Check if a profile version is “modern” based on version_detection modern_threshold

Parameters:

  • profile_version (String)

    the profile version

  • version_detection (Models::VersionDetection)

    the version detection config

Returns:

  • (Boolean, nil)

    true if profile is modern (>= threshold), false if legacy, nil if can’t determine



1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
# File 'lib/ukiryu/tool.rb', line 1090

def self.profile_is_modern?(profile_version, version_detection)
  return nil unless version_detection&.modern_threshold

  require 'rubygems/version'

  # Skip version comparison for non-numeric versions (e.g., "generic")
  return nil unless profile_version.match?(/^\d/)

  # Handle date-based versions (YYYY.MM.DD format used by some tools like ping_gnu)
  # These are release dates, not semantic versions
  # Compare by converting both to comparable formats
  if profile_version.match?(/^\d{4}\.\d{2}\.\d{2}$/) && version_detection.modern_threshold.match?(/^\d{8}$/)
    # Convert YYYY.MM.DD to YYYYMMDD for direct comparison
    profile_date = profile_version.gsub('.', '')
    threshold_date = version_detection.modern_threshold
    return profile_date >= threshold_date
  end

  profile_ver = Gem::Version.new(profile_version)
  threshold = Gem::Version.new(version_detection.modern_threshold)

  profile_ver >= threshold
rescue ArgumentError
  # If version parsing fails, treat as non-versioned (return nil)
  nil
end

.run_detection_command(detection, tool_name, options = {}) ⇒ String?

Run detection command for an implementation

Parameters:

  • detection (Hash)

    detection configuration

  • tool_name (String)

    the tool name for executable lookup

  • options (Hash) (defaults to: {})

    options

Returns:

  • (String, nil)

    command output or nil



151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
# File 'lib/ukiryu/tool.rb', line 151

def run_detection_command(detection, tool_name, options = {})
  command = detection[:command] || detection['command']
  return nil unless command

  cmd = Array(command)

  # Support multiple executables for detection (e.g., 'convert' and 'magick' for ImageMagick)
  executables = detection[:executables] || detection['executables']
  if executables
    # Try each executable until one succeeds
    Array(executables).each do |executable|
      full_cmd = [executable] + cmd
      result = try_execute_command(full_cmd, options)
      return result if result
    end
    nil
  else
    # Use the executable from detection config, or fall back to tool_name
    executable = detection[:executable] || detection['executable'] || options[:executable] || tool_name.to_s
    full_cmd = [executable] + cmd
    try_execute_command(full_cmd, options)
  end
end

.tools_cacheCache

Get the tools cache (bounded LRU cache)

Returns:

  • (Cache)

    the tools cache



43
44
45
# File 'lib/ukiryu/tool.rb', line 43

def tools_cache
  ToolCache.cache
end

.try_execute_command(cmd, options = {}) ⇒ String?

Try executing a command and return stdout on success

Parameters:

  • cmd (Array)

    command parts

  • options (Hash) (defaults to: {})

    options

Returns:

  • (String, nil)

    stdout or stderr (if command failed but has output) or nil



180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
# File 'lib/ukiryu/tool.rb', line 180

def try_execute_command(cmd, options = {})
  require_relative 'executor'
  require_relative 'shell'
  result = Executor.execute(
    cmd.first,
    cmd.drop(1),
    env: options[:env],
    shell: options[:shell] || Shell.detect,
    timeout: 5,
    allow_failure: true # Don't raise on non-zero exit for detection
  )
  # Scrub stdout/stderr to handle invalid UTF-8 byte sequences
  # If command succeeded, return stdout
  # If command failed but has stderr output, return stderr (for BusyBox detection)
  if result.success?
    result.stdout.scrub('')
  elsif !result.stderr.to_s.strip.empty?
    result.stderr.scrub('')
  end
rescue StandardError
  nil
end

Instance Method Details

#available?Boolean

Check if the tool is available

Returns:

  • (Boolean)


902
903
904
# File 'lib/ukiryu/tool.rb', line 902

def available?
  !@executable.nil?
end

#command?(command_name) ⇒ Boolean

Check if a command is available

Parameters:

  • command_name (Symbol)

    the command name

Returns:

  • (Boolean)


1057
1058
1059
# File 'lib/ukiryu/tool.rb', line 1057

def command?(command_name)
  !@command_profile.command(command_name.to_s).nil?
end

#command_definition(command_name) ⇒ CommandDefinition?

Get a command definition by name

Parameters:

  • command_name (String, Symbol)

    the command name

Returns:

  • (CommandDefinition, nil)

    the command definition or nil if not found



932
933
934
# File 'lib/ukiryu/tool.rb', line 932

def command_definition(command_name)
  @command_profile.command(command_name.to_s)
end

#commandsHash?

Get the commands defined in the active profile

Returns:

  • (Hash, nil)

    the commands hash



924
925
926
# File 'lib/ukiryu/tool.rb', line 924

def commands
  @command_profile.commands
end

#definition_mtimeTime?

Get the definition mtime if loaded from file

Returns:

  • (Time, nil)

    the file modification time



885
886
887
# File 'lib/ukiryu/tool.rb', line 885

def definition_mtime
  @definition_source&.mtime if @definition_source.respond_to?(:mtime)
end

#definition_pathString?

Get the definition path if loaded from file

Returns:

  • (String, nil)

    the file path



878
879
880
# File 'lib/ukiryu/tool.rb', line 878

def definition_path
  @definition_source&.path if @definition_source.respond_to?(:path)
end

#execute_simple(command_name, execution_timeout:, **params) ⇒ Executor::Result

Execute a command defined in the profile

Parameters:

  • command_name (Symbol)

    the command to execute

  • params (Hash, Object)

    command parameters (hash or options object)

  • execution_timeout (Integer)

    timeout in seconds for command execution (required)

Returns:

  • (Executor::Result)

    the execution result

Raises:

  • (ArgumentError)


982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
# File 'lib/ukiryu/tool.rb', line 982

def execute_simple(command_name, execution_timeout:, **params)
  # Debug logging for Ruby 4.0 CI
  if ENV['UKIRYU_DEBUG_EXECUTABLE']
    warn "[UKIRYU DEBUG execute_simple] command_name: #{command_name.inspect}"
    warn "[UKIRYU DEBUG execute_simple] params (before normalize): #{params.inspect}"
    warn "[UKIRYU DEBUG execute_simple] params.class: #{params.class}"
  end

  command = @command_profile.command(command_name.to_s)

  raise ArgumentError, "Unknown command: #{command_name}" unless command

  # Normalize params to hash with symbol keys
  params = normalize_params(params)

  warn "[UKIRYU DEBUG execute_simple] params (after normalize): #{params.inspect}" if ENV['UKIRYU_DEBUG_EXECUTABLE']

  # Extract stdin parameter if present (special parameter, not passed to command)
  stdin = params.delete(:stdin)

  # Build command arguments
  args = build_args(command, params)

  # Determine the executable to use
  # For tools with subcommands (v7 style for identify/mogrify), use @executable with the subcommand
  # For tools without subcommands, the behavior depends on the profile version:
  # - v7 (modern): convert has no subcommand but uses 'magick' executable
  # - v6 (legacy): each command (convert, identify, mogrify) is a standalone executable
  command_executable = if command.respond_to?(:has_subcommand?) && command.has_subcommand?
                         # v7 style: e.g., magick identify -> @executable is 'magick', subcommand is 'identify'
                         @executable
                       elsif command.respond_to?(:has_subcommand?) && !command.has_subcommand?
                         # No subcommand - need to determine if this is v7 or v6 style
                         # Check if profile has a modern_threshold and profile version is modern
                         if self.class.profile_is_modern?(@profile.version, @profile.version_detection)
                           # v7 style: convert command (no subcommand) uses 'magick' executable
                           @executable
                         else
                           # v6 style: each command is a standalone executable
                           # Check if command-specific executable exists on the filesystem
                           exe_dir = File.dirname(@executable)
                           exe_name = command.name
                           exe_path = File.join(exe_dir, exe_name)

                           # Use command-specific executable if profile explicitly allows it
                           # This is determined by checking if the command has standalone_executable: true
                           allows_standalone = if command.respond_to?(:standalone_executable?)
                                                 command.standalone_executable?
                                               else
                                                 false
                                               end

                           same_dir_as_exec = allows_standalone &&
                                              File.executable?(exe_path) &&
                                              File.dirname(exe_path) == exe_dir

                           if same_dir_as_exec
                             exe_path
                           else
                             @executable
                           end
                         end
                       else
                         # Fallback to @executable
                         @executable
                       end

  # Execute with environment and stdin, passing tool_name and command_name for exit code lookups
  execute_with_config(command_executable, args, command, params, execution_timeout: execution_timeout, stdin: stdin)
end

#execute_with_config(executable, args, command_def, params, execution_timeout:, stdin:) ⇒ Executor::Result

Execute command with common configuration

Parameters:

  • executable (String)

    the executable to run

  • args (Array)

    command arguments

  • command_def (Models::CommandDefinition)

    the command definition

  • params (Hash)

    command parameters

  • execution_timeout (Integer)

    timeout in seconds for command execution (required)

  • stdin (String, nil)

    optional stdin input

Returns:

  • (Executor::Result)

    the execution result



963
964
965
966
967
968
969
970
971
972
973
974
# File 'lib/ukiryu/tool.rb', line 963

def execute_with_config(executable, args, command_def, params, execution_timeout:, stdin:)
  Ukiryu::Executor.execute(
    executable,
    args,
    env: build_env_vars(command_def, @command_profile, params),
    timeout: execution_timeout,
    shell: @shell,
    stdin: stdin,
    tool_name: @profile.name,
    command_name: command_def.name
  )
end

#nameString

Get the tool name

Returns:

  • (String)

    the tool name



845
846
847
# File 'lib/ukiryu/tool.rb', line 845

def name
  @profile.name
end

#normalize_params(params) ⇒ Hash

Normalize params to hash

Converts params to a hash with symbol keys, handling both hash and options objects.

Parameters:

  • params (Hash, Object)

    the params to normalize

Returns:

  • (Hash)

    normalized hash with symbol keys



942
943
944
945
946
947
948
949
950
951
952
# File 'lib/ukiryu/tool.rb', line 942

def normalize_params(params)
  if params.is_a?(Hash) && params.keys.none? { |k| k.is_a?(Symbol) }
    # Likely has string keys from CLI, convert to symbols
    params.transform_keys(&:to_sym)
  elsif !params.is_a?(Hash)
    # It's an options object, convert to hash
    Ukiryu::OptionsBuilder.to_hash(params)
  else
    params
  end
end

#options_for(command_name) ⇒ Class

Get the options class for a command

Parameters:

  • command_name (Symbol)

    the command name

Returns:

  • (Class)

    the options class for this command



1065
1066
1067
# File 'lib/ukiryu/tool.rb', line 1065

def options_for(command_name)
  Ukiryu::OptionsBuilder.for(@profile.name, command_name)
end

#routingModels::Routing?

Get the routing table from the active profile

Returns:



1072
1073
1074
1075
1076
# File 'lib/ukiryu/tool.rb', line 1072

def routing
  return nil unless @command_profile.routing?

  @command_profile.routing
end

#routing?Boolean

Check if this tool has routing defined

Returns:

  • (Boolean)

    true if routing table is defined and non-empty



1081
1082
1083
# File 'lib/ukiryu/tool.rb', line 1081

def routing?
  !routing.nil? && !routing.empty?
end

#unavailability_reasonString?

Get the reason why the tool is not available

Returns nil if the tool is available, or a string explaining why not. This helps users understand issues like:

  • Tool not installed

  • Wrong version installed (e.g., impostor tool)

Returns:

  • (String, nil)

    reason for unavailability, or nil if available



914
915
916
917
918
919
# File 'lib/ukiryu/tool.rb', line 914

def unavailability_reason
  return nil if available?

  # Executable not found
  "Tool '#{name}' not found in PATH. Please install the tool and ensure it's in your PATH."
end

#versionString?

Get the tool version

Returns:

  • (String, nil)

    the tool version



852
853
854
855
856
857
858
859
860
861
# File 'lib/ukiryu/tool.rb', line 852

def version
  return @version if @version

  # Use profile version if available (from implementation detection)
  profile_version = @profile.version if @profile.respond_to?(:version)
  return profile_version if profile_version

  info = detect_version
  info&.to_s
end

#version_infoModels::VersionInfo?

Get the tool version info (full metadata)

Returns:



866
867
868
# File 'lib/ukiryu/tool.rb', line 866

def version_info
  @version_info ||= detect_version
end