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.
- checksums.yaml +4 -4
- data/.claude/commands/brainstorming-agent.md +227 -0
- data/.claude/commands/cli-test.md +251 -0
- data/.claude/commands/dev.md +234 -0
- data/.claude/commands/po.md +227 -0
- data/.claude/commands/progress.md +51 -0
- data/.claude/commands/uat.md +321 -0
- data/.rubocop.yml +9 -0
- data/AGENTS.md +43 -0
- data/CHANGELOG.md +12 -0
- data/CLAUDE.md +26 -3
- data/README.md +15 -0
- data/bin/dam +21 -1
- data/bin/jump.rb +29 -0
- data/bin/subtitle_processor.rb +54 -1
- data/bin/zsh_history.rb +846 -0
- data/docs/README.md +162 -69
- data/docs/architecture/cli/exe-bin-convention.md +434 -0
- data/docs/architecture/cli-patterns.md +631 -0
- data/docs/architecture/gpt-context/gpt-context-architecture.md +325 -0
- data/docs/architecture/gpt-context/gpt-context-implementation-guide.md +419 -0
- data/docs/architecture/gpt-context/gpt-context-vision.md +179 -0
- data/docs/architecture/testing/testing-patterns.md +762 -0
- data/docs/backlog.md +120 -0
- data/docs/cli-tests/FR-3-jump-location-tool.md +515 -0
- data/docs/specs/fr-002-gpt-context-help-system.md +265 -0
- data/docs/specs/fr-003-jump-location-tool.md +779 -0
- data/docs/specs/zsh-history-tool.md +820 -0
- data/docs/uat/FR-3-jump-location-tool.md +741 -0
- data/exe/jump +11 -0
- data/exe/{subtitle_manager → subtitle_processor} +1 -1
- data/exe/zsh_history +11 -0
- data/lib/appydave/tools/configuration/openai.rb +1 -1
- data/lib/appydave/tools/dam/file_helper.rb +28 -0
- data/lib/appydave/tools/dam/project_listing.rb +4 -30
- data/lib/appydave/tools/dam/s3_operations.rb +2 -1
- data/lib/appydave/tools/dam/ssd_status.rb +226 -0
- data/lib/appydave/tools/dam/status.rb +3 -51
- data/lib/appydave/tools/jump/cli.rb +561 -0
- data/lib/appydave/tools/jump/commands/add.rb +52 -0
- data/lib/appydave/tools/jump/commands/base.rb +43 -0
- data/lib/appydave/tools/jump/commands/generate.rb +153 -0
- data/lib/appydave/tools/jump/commands/remove.rb +58 -0
- data/lib/appydave/tools/jump/commands/report.rb +214 -0
- data/lib/appydave/tools/jump/commands/update.rb +42 -0
- data/lib/appydave/tools/jump/commands/validate.rb +54 -0
- data/lib/appydave/tools/jump/config.rb +233 -0
- data/lib/appydave/tools/jump/formatters/base.rb +48 -0
- data/lib/appydave/tools/jump/formatters/json_formatter.rb +19 -0
- data/lib/appydave/tools/jump/formatters/paths_formatter.rb +21 -0
- data/lib/appydave/tools/jump/formatters/table_formatter.rb +183 -0
- data/lib/appydave/tools/jump/location.rb +134 -0
- data/lib/appydave/tools/jump/path_validator.rb +47 -0
- data/lib/appydave/tools/jump/search.rb +230 -0
- data/lib/appydave/tools/subtitle_processor/transcript.rb +51 -0
- data/lib/appydave/tools/version.rb +1 -1
- data/lib/appydave/tools/zsh_history/command.rb +37 -0
- data/lib/appydave/tools/zsh_history/config.rb +235 -0
- data/lib/appydave/tools/zsh_history/filter.rb +184 -0
- data/lib/appydave/tools/zsh_history/formatter.rb +75 -0
- data/lib/appydave/tools/zsh_history/parser.rb +101 -0
- data/lib/appydave/tools.rb +25 -0
- data/package.json +1 -1
- 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
|