appydave-tools 0.70.0 → 0.71.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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/commands/brainstorming-agent.md +227 -0
  3. data/.claude/commands/cli-test.md +251 -0
  4. data/.claude/commands/dev.md +234 -0
  5. data/.claude/commands/po.md +227 -0
  6. data/.claude/commands/progress.md +51 -0
  7. data/.claude/commands/uat.md +321 -0
  8. data/.rubocop.yml +9 -0
  9. data/AGENTS.md +43 -0
  10. data/CHANGELOG.md +12 -0
  11. data/CLAUDE.md +26 -3
  12. data/README.md +15 -0
  13. data/bin/dam +21 -1
  14. data/bin/jump.rb +29 -0
  15. data/bin/subtitle_processor.rb +54 -1
  16. data/bin/zsh_history.rb +846 -0
  17. data/docs/README.md +162 -69
  18. data/docs/architecture/cli/exe-bin-convention.md +434 -0
  19. data/docs/architecture/cli-patterns.md +631 -0
  20. data/docs/architecture/gpt-context/gpt-context-architecture.md +325 -0
  21. data/docs/architecture/gpt-context/gpt-context-implementation-guide.md +419 -0
  22. data/docs/architecture/gpt-context/gpt-context-vision.md +179 -0
  23. data/docs/architecture/testing/testing-patterns.md +762 -0
  24. data/docs/backlog.md +120 -0
  25. data/docs/cli-tests/FR-3-jump-location-tool.md +515 -0
  26. data/docs/specs/fr-002-gpt-context-help-system.md +265 -0
  27. data/docs/specs/fr-003-jump-location-tool.md +779 -0
  28. data/docs/specs/zsh-history-tool.md +820 -0
  29. data/docs/uat/FR-3-jump-location-tool.md +741 -0
  30. data/exe/jump +11 -0
  31. data/exe/{subtitle_manager → subtitle_processor} +1 -1
  32. data/exe/zsh_history +11 -0
  33. data/lib/appydave/tools/configuration/openai.rb +1 -1
  34. data/lib/appydave/tools/dam/file_helper.rb +28 -0
  35. data/lib/appydave/tools/dam/project_listing.rb +4 -30
  36. data/lib/appydave/tools/dam/s3_operations.rb +2 -1
  37. data/lib/appydave/tools/dam/ssd_status.rb +226 -0
  38. data/lib/appydave/tools/dam/status.rb +3 -51
  39. data/lib/appydave/tools/jump/cli.rb +561 -0
  40. data/lib/appydave/tools/jump/commands/add.rb +52 -0
  41. data/lib/appydave/tools/jump/commands/base.rb +43 -0
  42. data/lib/appydave/tools/jump/commands/generate.rb +153 -0
  43. data/lib/appydave/tools/jump/commands/remove.rb +58 -0
  44. data/lib/appydave/tools/jump/commands/report.rb +214 -0
  45. data/lib/appydave/tools/jump/commands/update.rb +42 -0
  46. data/lib/appydave/tools/jump/commands/validate.rb +54 -0
  47. data/lib/appydave/tools/jump/config.rb +233 -0
  48. data/lib/appydave/tools/jump/formatters/base.rb +48 -0
  49. data/lib/appydave/tools/jump/formatters/json_formatter.rb +19 -0
  50. data/lib/appydave/tools/jump/formatters/paths_formatter.rb +21 -0
  51. data/lib/appydave/tools/jump/formatters/table_formatter.rb +183 -0
  52. data/lib/appydave/tools/jump/location.rb +134 -0
  53. data/lib/appydave/tools/jump/path_validator.rb +47 -0
  54. data/lib/appydave/tools/jump/search.rb +230 -0
  55. data/lib/appydave/tools/subtitle_processor/transcript.rb +51 -0
  56. data/lib/appydave/tools/version.rb +1 -1
  57. data/lib/appydave/tools/zsh_history/command.rb +37 -0
  58. data/lib/appydave/tools/zsh_history/config.rb +235 -0
  59. data/lib/appydave/tools/zsh_history/filter.rb +184 -0
  60. data/lib/appydave/tools/zsh_history/formatter.rb +75 -0
  61. data/lib/appydave/tools/zsh_history/parser.rb +101 -0
  62. data/lib/appydave/tools.rb +25 -0
  63. data/package.json +1 -1
  64. metadata +51 -4
@@ -0,0 +1,233 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Appydave
4
+ module Tools
5
+ module Jump
6
+ # Config manages the locations.json configuration file
7
+ #
8
+ # Follows the same pattern as other configuration models in appydave-tools,
9
+ # using ConfigBase for file loading/saving with automatic backups.
10
+ #
11
+ # @example Basic usage
12
+ # config = Config.new
13
+ # config.locations # => Array of Location objects
14
+ # config.brands # => Hash of brand definitions
15
+ # config.find('ad-tools') # => Location or nil
16
+ class Config
17
+ include KLog::Logging
18
+
19
+ CONFIG_VERSION = '1.0'
20
+ DEFAULT_CONFIG_NAME = 'locations'
21
+
22
+ attr_reader :data, :config_path
23
+
24
+ def initialize(config_path: nil)
25
+ @config_path = config_path || default_config_path
26
+ @data = load_config
27
+ end
28
+
29
+ # Get all locations as Location objects
30
+ #
31
+ # @return [Array<Location>]
32
+ def locations
33
+ @locations ||= (data['locations'] || []).map { |loc| Location.new(loc) }
34
+ end
35
+
36
+ # Reload locations from data (after modifications)
37
+ def reload_locations
38
+ @locations = nil
39
+ locations
40
+ end
41
+
42
+ # Get brand definitions
43
+ #
44
+ # @return [Hash]
45
+ def brands
46
+ data['brands'] || {}
47
+ end
48
+
49
+ # Get client definitions
50
+ #
51
+ # @return [Hash]
52
+ def clients
53
+ data['clients'] || {}
54
+ end
55
+
56
+ # Get category definitions
57
+ #
58
+ # @return [Hash]
59
+ def categories
60
+ data['categories'] || {}
61
+ end
62
+
63
+ # Get metadata
64
+ #
65
+ # @return [Hash]
66
+ def meta
67
+ data['meta'] || {}
68
+ end
69
+
70
+ # Find a location by key
71
+ #
72
+ # @param key [String] Location key
73
+ # @return [Location, nil]
74
+ def find(key)
75
+ locations.find { |loc| loc.key == key }
76
+ end
77
+
78
+ # Check if a location key exists
79
+ #
80
+ # @param key [String] Location key
81
+ # @return [Boolean]
82
+ def key_exists?(key)
83
+ locations.any? { |loc| loc.key == key }
84
+ end
85
+
86
+ # Add a new location
87
+ #
88
+ # @param location [Location, Hash] Location to add
89
+ # @return [Boolean] true if added successfully
90
+ # @raise [ArgumentError] if key already exists or location is invalid
91
+ def add(location)
92
+ location = Location.new(location) if location.is_a?(Hash)
93
+
94
+ raise ArgumentError, "Location key '#{location.key}' already exists" if key_exists?(location.key)
95
+
96
+ errors = location.validate
97
+ raise ArgumentError, "Invalid location: #{errors.join(', ')}" unless errors.empty?
98
+
99
+ data['locations'] ||= []
100
+ data['locations'] << location.to_h
101
+ reload_locations
102
+ true
103
+ end
104
+
105
+ # Update an existing location
106
+ #
107
+ # @param key [String] Key of location to update
108
+ # @param attrs [Hash] Attributes to update
109
+ # @return [Boolean] true if updated successfully
110
+ # @raise [ArgumentError] if location not found or updates are invalid
111
+ def update(key, attrs)
112
+ index = (data['locations'] || []).find_index { |loc| loc['key'] == key || loc[:key] == key }
113
+ raise ArgumentError, "Location '#{key}' not found" if index.nil?
114
+
115
+ # Merge attributes
116
+ current = data['locations'][index].transform_keys(&:to_sym)
117
+ updated_attrs = current.merge(attrs.transform_keys(&:to_sym))
118
+
119
+ # Validate
120
+ updated = Location.new(updated_attrs)
121
+ errors = updated.validate
122
+ raise ArgumentError, "Invalid update: #{errors.join(', ')}" unless errors.empty?
123
+
124
+ data['locations'][index] = updated.to_h.transform_keys(&:to_s)
125
+ reload_locations
126
+ true
127
+ end
128
+
129
+ # Remove a location by key
130
+ #
131
+ # @param key [String] Key of location to remove
132
+ # @return [Boolean] true if removed
133
+ # @raise [ArgumentError] if location not found
134
+ def remove(key)
135
+ index = (data['locations'] || []).find_index { |loc| loc['key'] == key || loc[:key] == key }
136
+ raise ArgumentError, "Location '#{key}' not found" if index.nil?
137
+
138
+ data['locations'].delete_at(index)
139
+ reload_locations
140
+ true
141
+ end
142
+
143
+ # Save configuration to file with backup
144
+ #
145
+ # @return [void]
146
+ def save
147
+ # Create backup if file exists
148
+ if File.exist?(config_path)
149
+ backup_path = "#{config_path}.backup.#{Time.now.strftime('%Y%m%d-%H%M%S')}"
150
+ FileUtils.cp(config_path, backup_path)
151
+ end
152
+
153
+ # Update timestamp
154
+ data['meta'] ||= {}
155
+ data['meta']['version'] = CONFIG_VERSION
156
+ data['meta']['last_updated'] = Time.now.utc.iso8601
157
+
158
+ # Ensure directory exists
159
+ FileUtils.mkdir_p(File.dirname(config_path))
160
+
161
+ # Write atomically (temp file then rename)
162
+ temp_path = "#{config_path}.tmp"
163
+ File.write(temp_path, JSON.pretty_generate(data))
164
+ File.rename(temp_path, config_path)
165
+ end
166
+
167
+ # Update validation timestamp
168
+ #
169
+ # @return [void]
170
+ def touch_validated
171
+ data['meta'] ||= {}
172
+ data['meta']['last_validated'] = Time.now.utc.iso8601
173
+ end
174
+
175
+ # Get info about the configuration
176
+ #
177
+ # @return [Hash]
178
+ def info
179
+ {
180
+ config_path: config_path,
181
+ exists: File.exist?(config_path),
182
+ version: meta['version'],
183
+ last_updated: meta['last_updated'],
184
+ last_validated: meta['last_validated'],
185
+ location_count: locations.size,
186
+ brand_count: brands.size,
187
+ client_count: clients.size
188
+ }
189
+ end
190
+
191
+ private
192
+
193
+ def default_config_path
194
+ File.join(Configuration::Config.config_path, "#{DEFAULT_CONFIG_NAME}.json")
195
+ end
196
+
197
+ def load_config
198
+ return default_data unless File.exist?(config_path)
199
+
200
+ content = File.read(config_path)
201
+ JSON.parse(content)
202
+ rescue JSON::ParserError => e
203
+ log.error "JSON parse error in #{config_path}: #{e.message}"
204
+ default_data
205
+ rescue StandardError => e
206
+ log.error "Error loading #{config_path}: #{e.message}"
207
+ default_data
208
+ end
209
+
210
+ def default_data
211
+ {
212
+ 'meta' => {
213
+ 'version' => CONFIG_VERSION
214
+ },
215
+ 'categories' => {
216
+ 'type' => {
217
+ 'description' => 'Kind of location',
218
+ 'values' => %w[brand client gem video brain site tool config]
219
+ },
220
+ 'technology' => {
221
+ 'description' => 'Primary language/framework',
222
+ 'values' => %w[ruby javascript typescript python astro]
223
+ }
224
+ },
225
+ 'brands' => {},
226
+ 'clients' => {},
227
+ 'locations' => []
228
+ }
229
+ end
230
+ end
231
+ end
232
+ end
233
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Appydave
4
+ module Tools
5
+ module Jump
6
+ module Formatters
7
+ # Base formatter class providing common functionality
8
+ class Base
9
+ attr_reader :data, :options
10
+
11
+ def initialize(data, options = {})
12
+ @data = data
13
+ @options = options
14
+ end
15
+
16
+ # Format the data
17
+ #
18
+ # @return [String] Formatted output
19
+ def format
20
+ raise NotImplementedError, 'Subclasses must implement #format'
21
+ end
22
+
23
+ protected
24
+
25
+ def results
26
+ data[:results] || []
27
+ end
28
+
29
+ def success?
30
+ data[:success]
31
+ end
32
+
33
+ def error_message
34
+ data[:error]
35
+ end
36
+
37
+ def suggestion
38
+ data[:suggestion]
39
+ end
40
+
41
+ def count
42
+ data[:count] || 0
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Appydave
4
+ module Tools
5
+ module Jump
6
+ module Formatters
7
+ # JSON formatter for programmatic access and Claude skill integration
8
+ class JsonFormatter < Base
9
+ # Format data as JSON
10
+ #
11
+ # @return [String] JSON string
12
+ def format
13
+ JSON.pretty_generate(data)
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Appydave
4
+ module Tools
5
+ module Jump
6
+ module Formatters
7
+ # Paths formatter outputs one path per line for scripting
8
+ class PathsFormatter < Base
9
+ # Format data as one path per line
10
+ #
11
+ # @return [String] Newline-separated paths
12
+ def format
13
+ return '' unless success?
14
+
15
+ results.map { |r| r[:path] }.compact.join("\n")
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Appydave
4
+ module Tools
5
+ module Jump
6
+ module Formatters
7
+ # Table formatter for human-readable terminal output with colors
8
+ class TableFormatter < Base
9
+ # ANSI color codes
10
+ COLORS = {
11
+ reset: "\e[0m",
12
+ bold: "\e[1m",
13
+ dim: "\e[2m",
14
+ red: "\e[31m",
15
+ green: "\e[32m",
16
+ yellow: "\e[33m",
17
+ blue: "\e[34m",
18
+ magenta: "\e[35m",
19
+ cyan: "\e[36m",
20
+ white: "\e[37m"
21
+ }.freeze
22
+
23
+ # Format data as a colored table
24
+ #
25
+ # @return [String] Formatted table
26
+ def format
27
+ return format_error unless success?
28
+ return format_info if info_result?
29
+ return format_empty if results.empty?
30
+
31
+ format_results
32
+ end
33
+
34
+ private
35
+
36
+ def format_error
37
+ lines = []
38
+ lines << colorize("Error: #{error_message}", :red)
39
+ lines << colorize(suggestion, :yellow) if suggestion
40
+ lines.join("\n")
41
+ end
42
+
43
+ def format_empty
44
+ colorize('No locations found.', :yellow)
45
+ end
46
+
47
+ def info_result?
48
+ data.key?(:config_path)
49
+ end
50
+
51
+ def format_info
52
+ [
53
+ format_info_header,
54
+ format_info_details,
55
+ format_info_statistics
56
+ ].join("\n")
57
+ end
58
+
59
+ def format_info_header
60
+ [
61
+ colorize('Jump Location Tool - Configuration Info', :bold),
62
+ ''
63
+ ]
64
+ end
65
+
66
+ def format_info_details
67
+ exists_value = data[:exists] ? colorize('Yes', :green) : colorize('No', :red)
68
+ [
69
+ "#{colorize('Config Path:', :cyan)} #{data[:config_path]}",
70
+ "#{colorize('Config Exists:', :cyan)} #{exists_value}",
71
+ "#{colorize('Version:', :cyan)} #{data[:version] || 'N/A'}",
72
+ "#{colorize('Last Updated:', :cyan)} #{data[:last_updated] || 'Never'}",
73
+ "#{colorize('Last Validated:', :cyan)} #{data[:last_validated] || 'Never'}",
74
+ ''
75
+ ]
76
+ end
77
+
78
+ def format_info_statistics
79
+ [
80
+ colorize('Statistics:', :bold),
81
+ " Locations: #{data[:location_count] || 0}",
82
+ " Brands: #{data[:brand_count] || 0}",
83
+ " Clients: #{data[:client_count] || 0}"
84
+ ]
85
+ end
86
+
87
+ def format_results
88
+ lines = []
89
+
90
+ # Header
91
+ lines << format_header
92
+ lines << header_separator
93
+
94
+ # Results
95
+ results.each do |result|
96
+ lines << format_row(result)
97
+ end
98
+
99
+ # Footer
100
+ lines << ''
101
+ lines << colorize("Total: #{count} location(s)", :dim)
102
+
103
+ lines.join("\n")
104
+ end
105
+
106
+ def format_header
107
+ cols = [
108
+ pad('#', 3),
109
+ pad('KEY', key_width),
110
+ pad('JUMP', jump_width),
111
+ pad('TYPE', 10),
112
+ pad('BRAND/CLIENT', 15),
113
+ 'DESCRIPTION'
114
+ ]
115
+ colorize(cols.join(' '), :bold)
116
+ end
117
+
118
+ def header_separator
119
+ '-' * terminal_width
120
+ end
121
+
122
+ # rubocop:disable Metrics/AbcSize
123
+ def format_row(result)
124
+ index = result[:index].to_s.rjust(3)
125
+ key = pad(result[:key] || '', key_width)
126
+ jump = pad(result[:jump] || '', jump_width)
127
+ type = pad(result[:type] || '', 10)
128
+ owner = pad(result[:brand] || result[:client] || '', 15)
129
+ desc = truncate(result[:description] || '', description_width)
130
+
131
+ # Color the score indicator
132
+ score_indicator = if result[:score]&.positive?
133
+ colorize("[#{result[:score]}]", :cyan)
134
+ else
135
+ ''
136
+ end
137
+
138
+ "#{colorize(index, :dim)} #{colorize(key, :green)} #{colorize(jump, :blue)} " \
139
+ "#{type} #{colorize(owner, :magenta)} #{desc} #{score_indicator}"
140
+ end
141
+ # rubocop:enable Metrics/AbcSize
142
+
143
+ def key_width
144
+ @key_width ||= [results.map { |r| (r[:key] || '').length }.max || 10, 20].min
145
+ end
146
+
147
+ def jump_width
148
+ @jump_width ||= [results.map { |r| (r[:jump] || '').length }.max || 10, 15].min
149
+ end
150
+
151
+ def description_width
152
+ @description_width ||= [terminal_width - 60, 30].max
153
+ end
154
+
155
+ def terminal_width
156
+ @terminal_width ||= begin
157
+ width = ENV.fetch('COLUMNS', nil)&.to_i
158
+ width = `tput cols 2>/dev/null`.to_i if width.nil? || width.zero?
159
+ width = 120 if width.zero?
160
+ width
161
+ end
162
+ end
163
+
164
+ def pad(str, width)
165
+ str.to_s.ljust(width)[0...width]
166
+ end
167
+
168
+ def truncate(str, width)
169
+ return str if str.length <= width
170
+
171
+ "#{str[0...(width - 3)]}..."
172
+ end
173
+
174
+ def colorize(text, color)
175
+ return text unless options[:color] != false && $stdout.tty?
176
+
177
+ "#{COLORS[color]}#{text}#{COLORS[:reset]}"
178
+ end
179
+ end
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Appydave
4
+ module Tools
5
+ module Jump
6
+ # Location represents a single development folder location
7
+ #
8
+ # @example Creating a location
9
+ # location = Location.new(
10
+ # key: 'ad-tools',
11
+ # path: '~/dev/ad/appydave-tools',
12
+ # jump: 'jad-tools',
13
+ # brand: 'appydave',
14
+ # type: 'tool',
15
+ # tags: ['ruby', 'cli'],
16
+ # description: 'AppyDave CLI tools'
17
+ # )
18
+ class Location
19
+ VALID_KEY_PATTERN = /\A[a-z0-9][a-z0-9-]*[a-z0-9]\z|\A[a-z0-9]\z/.freeze
20
+ VALID_PATH_PATTERN = %r{\A[~/]}.freeze
21
+ VALID_TAG_PATTERN = /\A[a-z0-9][a-z0-9-]*[a-z0-9]\z|\A[a-z0-9]\z/.freeze
22
+
23
+ attr_reader :key, :path, :jump, :brand, :client, :type, :tags, :description
24
+
25
+ def initialize(attrs = {})
26
+ attrs = normalize_attrs(attrs)
27
+
28
+ @key = attrs[:key]
29
+ @path = attrs[:path]
30
+ @jump = attrs[:jump] || default_jump
31
+ @brand = attrs[:brand]
32
+ @client = attrs[:client]
33
+ @type = attrs[:type]
34
+ @tags = Array(attrs[:tags])
35
+ @description = attrs[:description]
36
+ end
37
+
38
+ # Validate the location
39
+ #
40
+ # @return [Array<String>] List of validation errors (empty if valid)
41
+ def validate
42
+ errors = []
43
+ errors << 'Key is required' if key.nil? || key.empty?
44
+ errors << "Key '#{key}' is invalid (must be lowercase alphanumeric with hyphens)" if key && !valid_key?
45
+ errors << 'Path is required' if path.nil? || path.empty?
46
+ errors << "Path '#{path}' is invalid (must start with ~ or /)" if path && !valid_path?
47
+ errors.concat(validate_tags)
48
+ errors
49
+ end
50
+
51
+ # Check if location is valid
52
+ #
53
+ # @return [Boolean]
54
+ def valid?
55
+ validate.empty?
56
+ end
57
+
58
+ # Convert to hash for JSON serialization
59
+ #
60
+ # @return [Hash]
61
+ def to_h
62
+ {
63
+ key: key,
64
+ path: path,
65
+ jump: jump,
66
+ brand: brand,
67
+ client: client,
68
+ type: type,
69
+ tags: tags,
70
+ description: description
71
+ }.compact
72
+ end
73
+
74
+ # Get all searchable text for this location
75
+ #
76
+ # @param brands [Hash] Brand definitions with aliases
77
+ # @param clients [Hash] Client definitions with aliases
78
+ # @return [Array<String>] All searchable terms
79
+ def searchable_terms(brands: {}, clients: {})
80
+ terms = [key, path, type, description].compact
81
+ terms.concat(tags)
82
+
83
+ # Add brand and its aliases
84
+ if brand && brands[brand]
85
+ terms << brand
86
+ terms.concat(Array(brands[brand]['aliases'] || brands[brand][:aliases]))
87
+ elsif brand
88
+ terms << brand
89
+ end
90
+
91
+ # Add client and its aliases
92
+ if client && clients[client]
93
+ terms << client
94
+ terms.concat(Array(clients[client]['aliases'] || clients[client][:aliases]))
95
+ elsif client
96
+ terms << client
97
+ end
98
+
99
+ terms.compact.map(&:to_s).map(&:downcase)
100
+ end
101
+
102
+ private
103
+
104
+ def normalize_attrs(attrs)
105
+ attrs.transform_keys(&:to_sym)
106
+ end
107
+
108
+ def default_jump
109
+ return nil unless key
110
+
111
+ "j#{key}"
112
+ end
113
+
114
+ def valid_key?
115
+ key.match?(VALID_KEY_PATTERN)
116
+ end
117
+
118
+ def valid_path?
119
+ path.match?(VALID_PATH_PATTERN)
120
+ end
121
+
122
+ def validate_tags
123
+ errors = []
124
+ tags.each do |tag|
125
+ next if tag.match?(VALID_TAG_PATTERN)
126
+
127
+ errors << "Tag '#{tag}' is invalid (must be lowercase alphanumeric with hyphens)"
128
+ end
129
+ errors
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Appydave
4
+ module Tools
5
+ module Jump
6
+ # PathValidator checks if filesystem paths exist
7
+ #
8
+ # This class is designed for dependency injection in tests.
9
+ # Production code uses the real filesystem, while tests inject
10
+ # a mock that returns predetermined results.
11
+ #
12
+ # @example Production usage
13
+ # validator = PathValidator.new
14
+ # validator.exists?('~/dev/project') # => true/false based on real filesystem
15
+ #
16
+ # @example Test usage (see spec/support/jump_test_helpers.rb)
17
+ # validator = TestPathValidator.new(valid_paths: ['~/dev/project'])
18
+ # validator.exists?('~/dev/project') # => true
19
+ # validator.exists?('~/dev/other') # => false
20
+ class PathValidator
21
+ # Check if a path exists as a directory
22
+ #
23
+ # @param path [String] Path to check (supports ~ expansion)
24
+ # @return [Boolean] true if directory exists
25
+ def exists?(path)
26
+ File.directory?(expand(path))
27
+ end
28
+
29
+ # Check if a path exists as a file
30
+ #
31
+ # @param path [String] Path to check (supports ~ expansion)
32
+ # @return [Boolean] true if file exists
33
+ def file_exists?(path)
34
+ File.exist?(expand(path))
35
+ end
36
+
37
+ # Expand a path (resolve ~ and relative paths)
38
+ #
39
+ # @param path [String] Path to expand
40
+ # @return [String] Absolute path
41
+ def expand(path)
42
+ File.expand_path(path)
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end