appydave-tools 0.83.0 → 0.85.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/CHANGELOG.md +19 -0
- data/CLAUDE.md +1 -1
- data/CONTEXT.md +133 -23
- data/bin/configuration.rb +20 -0
- data/bin/query_apps.rb +74 -0
- data/config/examples/locations.example.json +48 -0
- data/config/examples/settings.example.json +9 -0
- data/config/random-queries.yml +24 -45
- data/context.globs.json +24 -0
- data/docs/planning/multi-user-support.md +108 -0
- data/docs/planning/query-apps-design.md +344 -0
- data/docs/planning/query-location-feature/IMPLEMENTATION_PLAN.md +142 -0
- data/docs/planning/query-location-feature/system-context-gap-analysis.md +107 -0
- data/docs/planning/query-skills-plan.md +354 -0
- data/docs/specs/jump-add-display-fix.md +249 -0
- data/exe/query_apps +7 -0
- data/lib/appydave/tools/app_context/app_finder.rb +176 -0
- data/lib/appydave/tools/app_context/globs_loader.rb +116 -0
- data/lib/appydave/tools/app_context/options.rb +28 -0
- data/lib/appydave/tools/brain_context/options.rb +19 -2
- data/lib/appydave/tools/configuration/example_installer.rb +72 -0
- data/lib/appydave/tools/configuration/models/settings_config.rb +12 -0
- data/lib/appydave/tools/jump/cli.rb +78 -0
- data/lib/appydave/tools/jump/commands/query.rb +112 -0
- data/lib/appydave/tools/jump/formatters/table_formatter.rb +16 -5
- data/lib/appydave/tools/version.rb +1 -1
- data/lib/appydave/tools/zsh_history/config.rb +2 -2
- data/lib/appydave/tools/zsh_history/filter.rb +10 -4
- data/lib/appydave/tools.rb +6 -0
- data/package.json +1 -1
- metadata +19 -2
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Appydave
|
|
6
|
+
module Tools
|
|
7
|
+
module AppContext
|
|
8
|
+
# Queries locations.json to find app files via context.globs.json patterns.
|
|
9
|
+
#
|
|
10
|
+
# Follows the same find/find_meta pattern as BrainQuery and OmiQuery.
|
|
11
|
+
class AppQuery
|
|
12
|
+
def initialize(options, jump_config: nil)
|
|
13
|
+
@options = options
|
|
14
|
+
@jump_config = jump_config
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Return absolute file paths matching the query
|
|
18
|
+
def find
|
|
19
|
+
return [] unless @options.query?
|
|
20
|
+
|
|
21
|
+
apps = resolve_apps
|
|
22
|
+
return [] if apps.empty?
|
|
23
|
+
|
|
24
|
+
apps.flat_map { |app| expand_app(app) }.uniq.sort
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Return structured metadata about the query results
|
|
28
|
+
def find_meta
|
|
29
|
+
return [] unless @options.query?
|
|
30
|
+
|
|
31
|
+
apps = resolve_apps
|
|
32
|
+
return [] if apps.empty?
|
|
33
|
+
|
|
34
|
+
apps.map { |app| build_meta(app) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# List all available glob names for a specific app
|
|
38
|
+
def list_globs(app_name)
|
|
39
|
+
location = find_location(app_name)
|
|
40
|
+
return [] unless location
|
|
41
|
+
|
|
42
|
+
loader = build_loader(location)
|
|
43
|
+
return [] unless loader.available?
|
|
44
|
+
|
|
45
|
+
loader.available_names
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# List all apps that have context.globs.json
|
|
49
|
+
def list_apps
|
|
50
|
+
jump_config.locations
|
|
51
|
+
.select { |loc| File.exist?(File.join(expand_path(loc.path), 'context.globs.json')) }
|
|
52
|
+
.map do |loc|
|
|
53
|
+
loader = build_loader(loc)
|
|
54
|
+
{
|
|
55
|
+
'key' => loc.key,
|
|
56
|
+
'path' => expand_path(loc.path),
|
|
57
|
+
'pattern' => loader.pattern,
|
|
58
|
+
'glob_count' => loader.globs.size
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def jump_config
|
|
66
|
+
@jump_config ||= Jump::Config.new
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Resolve app names to Location objects
|
|
70
|
+
def resolve_apps
|
|
71
|
+
if @options.pattern_filter
|
|
72
|
+
resolve_by_pattern(@options.pattern_filter)
|
|
73
|
+
else
|
|
74
|
+
@options.app_names.flat_map { |name| resolve_app(name) }.compact.uniq(&:key)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# 4-tier app resolution
|
|
79
|
+
def resolve_app(name)
|
|
80
|
+
name_down = name.downcase
|
|
81
|
+
|
|
82
|
+
# Tier 1: exact key match
|
|
83
|
+
loc = jump_config.find(name_down)
|
|
84
|
+
return [loc] if loc
|
|
85
|
+
|
|
86
|
+
# Tier 2: jump alias match
|
|
87
|
+
loc = jump_config.locations.find { |l| l.jump&.downcase == name_down }
|
|
88
|
+
return [loc] if loc
|
|
89
|
+
|
|
90
|
+
# Tier 3: substring match on key
|
|
91
|
+
matches = jump_config.locations.select { |l| l.key.downcase.include?(name_down) }
|
|
92
|
+
return matches unless matches.empty?
|
|
93
|
+
|
|
94
|
+
# Tier 4: substring match on description
|
|
95
|
+
jump_config.locations.select { |l| l.description&.downcase&.include?(name_down) }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def resolve_by_pattern(pattern)
|
|
99
|
+
jump_config.locations.select do |loc|
|
|
100
|
+
loader = build_loader(loc)
|
|
101
|
+
loader.available? && loader.pattern&.downcase == pattern.downcase
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def expand_app(location)
|
|
106
|
+
loader = build_loader(location)
|
|
107
|
+
return [] unless loader.available?
|
|
108
|
+
|
|
109
|
+
glob_names = @options.glob_names
|
|
110
|
+
return [] if glob_names.empty?
|
|
111
|
+
|
|
112
|
+
loader.expand(glob_names)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def build_meta(location)
|
|
116
|
+
loader = build_loader(location)
|
|
117
|
+
glob_names = @options.glob_names
|
|
118
|
+
file_count = loader.available? && glob_names.any? ? loader.expand(glob_names).size : 0
|
|
119
|
+
|
|
120
|
+
resolved_from = glob_names.map { |n| describe_resolution(loader, n) }.join(', ')
|
|
121
|
+
|
|
122
|
+
{
|
|
123
|
+
'app' => location.key,
|
|
124
|
+
'path' => expand_path(location.path),
|
|
125
|
+
'pattern' => loader.pattern,
|
|
126
|
+
'matched_globs' => resolve_glob_names(loader, glob_names),
|
|
127
|
+
'resolved_from' => resolved_from,
|
|
128
|
+
'file_count' => file_count
|
|
129
|
+
}
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def build_loader(location)
|
|
133
|
+
GlobsLoader.new(expand_path(location.path))
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def expand_path(path)
|
|
137
|
+
File.expand_path(path)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def find_location(app_name)
|
|
141
|
+
results = resolve_app(app_name)
|
|
142
|
+
results&.first
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Describe how a glob name was resolved (for meta output)
|
|
146
|
+
def describe_resolution(loader, name)
|
|
147
|
+
name = name.strip.downcase
|
|
148
|
+
return "#{name} (glob)" if loader.globs.key?(name)
|
|
149
|
+
return "#{name} (alias)" if loader.aliases.key?(name)
|
|
150
|
+
return "#{name} (composite)" if loader.composites.key?(name)
|
|
151
|
+
|
|
152
|
+
"#{name} (fuzzy)"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Resolve glob names to their constituent direct glob names
|
|
156
|
+
def resolve_glob_names(loader, names)
|
|
157
|
+
return [] unless loader.available?
|
|
158
|
+
|
|
159
|
+
names.flat_map do |name|
|
|
160
|
+
name = name.strip.downcase
|
|
161
|
+
if loader.globs.key?(name)
|
|
162
|
+
[name]
|
|
163
|
+
elsif loader.aliases.key?(name)
|
|
164
|
+
loader.aliases[name]
|
|
165
|
+
elsif loader.composites.key?(name)
|
|
166
|
+
members = loader.composites[name]
|
|
167
|
+
members == ['*'] ? loader.globs.keys : members
|
|
168
|
+
else # rubocop:disable Lint/DuplicateBranch
|
|
169
|
+
[name]
|
|
170
|
+
end
|
|
171
|
+
end.uniq
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Appydave
|
|
6
|
+
module Tools
|
|
7
|
+
module AppContext
|
|
8
|
+
# Loads and resolves named glob patterns from a context.globs.json file.
|
|
9
|
+
#
|
|
10
|
+
# Resolution is 3-tier:
|
|
11
|
+
# 1. Direct glob name — "services" → globs["services"]
|
|
12
|
+
# 2. Alias match — "backend" → aliases["backend"] → ["services", "routes"]
|
|
13
|
+
# 3. Composite match — "understand" → composites["understand"] → ["context", "docs", ...]
|
|
14
|
+
class GlobsLoader
|
|
15
|
+
attr_reader :project_path, :data
|
|
16
|
+
|
|
17
|
+
def initialize(project_path)
|
|
18
|
+
@project_path = project_path
|
|
19
|
+
@data = load_globs_file
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def available?
|
|
23
|
+
!data.nil?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def globs
|
|
27
|
+
data&.fetch('globs', {}) || {}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def aliases
|
|
31
|
+
data&.fetch('aliases', {}) || {}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def composites
|
|
35
|
+
data&.fetch('composites', {}) || {}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def pattern
|
|
39
|
+
data&.fetch('pattern', nil)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def project_name
|
|
43
|
+
data&.fetch('project', nil)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# List all available glob names (direct + aliases + composites)
|
|
47
|
+
def available_names
|
|
48
|
+
names = globs.keys.map { |k| { name: k, type: 'glob' } }
|
|
49
|
+
names += aliases.keys.map { |k| { name: k, type: 'alias' } }
|
|
50
|
+
names += composites.keys.map { |k| { name: k, type: 'composite' } }
|
|
51
|
+
names
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Resolve a single name through the 3-tier hierarchy.
|
|
55
|
+
# Returns an array of raw glob patterns (strings).
|
|
56
|
+
def resolve(name)
|
|
57
|
+
name = name.strip.downcase
|
|
58
|
+
|
|
59
|
+
# Tier 1: direct glob name
|
|
60
|
+
return globs[name] if globs.key?(name)
|
|
61
|
+
|
|
62
|
+
# Tier 2: alias → list of glob names
|
|
63
|
+
return aliases[name].flat_map { |glob_name| globs[glob_name] || [] } if aliases.key?(name)
|
|
64
|
+
|
|
65
|
+
# Tier 3: composite → list of glob names (or "*" for all)
|
|
66
|
+
if composites.key?(name)
|
|
67
|
+
members = composites[name]
|
|
68
|
+
return globs.values.flatten if members == ['*']
|
|
69
|
+
|
|
70
|
+
return members.flat_map { |glob_name| resolve_single_glob(glob_name) }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Tier 4: substring fallback
|
|
74
|
+
match = find_substring_match(name)
|
|
75
|
+
return resolve(match) if match
|
|
76
|
+
|
|
77
|
+
[]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Resolve multiple names, expand globs against the filesystem, return absolute paths.
|
|
81
|
+
def expand(names)
|
|
82
|
+
patterns = names.flat_map { |name| resolve(name) }.uniq
|
|
83
|
+
|
|
84
|
+
patterns.flat_map { |pat| Dir.glob(File.join(project_path, pat)) }
|
|
85
|
+
.select { |f| File.file?(f) }
|
|
86
|
+
.uniq
|
|
87
|
+
.sort
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def globs_file_path
|
|
93
|
+
File.join(project_path, 'context.globs.json')
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def load_globs_file
|
|
97
|
+
path = globs_file_path
|
|
98
|
+
return nil unless File.exist?(path)
|
|
99
|
+
|
|
100
|
+
JSON.parse(File.read(path))
|
|
101
|
+
rescue JSON::ParserError
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def resolve_single_glob(glob_name)
|
|
106
|
+
globs[glob_name] || []
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def find_substring_match(name)
|
|
110
|
+
all_names = globs.keys + aliases.keys + composites.keys
|
|
111
|
+
all_names.find { |n| n.include?(name) }
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Appydave
|
|
4
|
+
module Tools
|
|
5
|
+
module AppContext
|
|
6
|
+
# Options struct for app context query tool
|
|
7
|
+
class Options
|
|
8
|
+
attr_accessor :app_names, :glob_names, :pattern_filter,
|
|
9
|
+
:meta, :list, :list_apps,
|
|
10
|
+
:debug_level
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
@app_names = []
|
|
14
|
+
@glob_names = []
|
|
15
|
+
@pattern_filter = nil
|
|
16
|
+
@meta = false
|
|
17
|
+
@list = false
|
|
18
|
+
@list_apps = false
|
|
19
|
+
@debug_level = 'none'
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def query?
|
|
23
|
+
app_names.any? || !pattern_filter.nil?
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -34,11 +34,15 @@ module Appydave
|
|
|
34
34
|
@tokens = false
|
|
35
35
|
@base_dir = Dir.pwd
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
configured_omi = read_setting('omi-directory-path')
|
|
38
|
+
@omi_dir = configured_omi || File.expand_path('~/dev/raw-intake/omi')
|
|
38
39
|
end
|
|
39
40
|
|
|
40
41
|
def brains_root
|
|
41
|
-
@brains_root ||=
|
|
42
|
+
@brains_root ||= begin
|
|
43
|
+
configured = read_setting('brains-root-path')
|
|
44
|
+
configured || File.expand_path('~/dev/ad/brains')
|
|
45
|
+
end
|
|
42
46
|
end
|
|
43
47
|
|
|
44
48
|
def brains_index_path
|
|
@@ -52,6 +56,19 @@ module Appydave
|
|
|
52
56
|
def omi_query?
|
|
53
57
|
omi
|
|
54
58
|
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# Read a path value from settings config, expanding ~ if set.
|
|
63
|
+
# Returns nil if config is unavailable or key is absent/blank.
|
|
64
|
+
def read_setting(key)
|
|
65
|
+
value = Appydave::Tools::Configuration::Config.settings.get(key)
|
|
66
|
+
return nil if value.nil? || value.to_s.strip.empty?
|
|
67
|
+
|
|
68
|
+
File.expand_path(value)
|
|
69
|
+
rescue StandardError
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
55
72
|
end
|
|
56
73
|
end
|
|
57
74
|
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Appydave
|
|
4
|
+
module Tools
|
|
5
|
+
module Configuration
|
|
6
|
+
# Installs bundled example configuration files into the user's config directory.
|
|
7
|
+
#
|
|
8
|
+
# Example files live at config/examples/*.example.json inside the gem.
|
|
9
|
+
# Each file is installed without the `.example` segment in its name so that
|
|
10
|
+
# `settings.example.json` becomes `settings.json` in the target directory.
|
|
11
|
+
#
|
|
12
|
+
# Files are never overwritten — existing files are skipped and reported.
|
|
13
|
+
#
|
|
14
|
+
# @example Install all examples
|
|
15
|
+
# result = ExampleInstaller.new.install
|
|
16
|
+
# result[:installed] #=> ["settings.json", "locations.json"]
|
|
17
|
+
# result[:skipped] #=> []
|
|
18
|
+
class ExampleInstaller
|
|
19
|
+
EXAMPLES_PATH = File.expand_path('../../../../config/examples', __dir__)
|
|
20
|
+
|
|
21
|
+
# @param target_path [String, nil] Directory to install into.
|
|
22
|
+
# Defaults to the active Config.config_path (~/.config/appydave).
|
|
23
|
+
def initialize(target_path: nil)
|
|
24
|
+
@target_path = target_path || Config.config_path
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Install all bundled example files that do not yet exist.
|
|
28
|
+
#
|
|
29
|
+
# @return [Hash] with keys :installed (Array<String>) and :skipped (Array<String>)
|
|
30
|
+
def install
|
|
31
|
+
FileUtils.mkdir_p(@target_path)
|
|
32
|
+
results = { installed: [], skipped: [] }
|
|
33
|
+
|
|
34
|
+
example_files.each do |src|
|
|
35
|
+
dest = destination_for(src)
|
|
36
|
+
basename = File.basename(dest)
|
|
37
|
+
|
|
38
|
+
if File.exist?(dest)
|
|
39
|
+
results[:skipped] << basename
|
|
40
|
+
else
|
|
41
|
+
FileUtils.cp(src, dest)
|
|
42
|
+
results[:installed] << basename
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
results
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# List the filenames that would be installed (target names, not source names).
|
|
50
|
+
#
|
|
51
|
+
# @return [Array<String>]
|
|
52
|
+
def available
|
|
53
|
+
example_files.map { |f| target_name(f) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def example_files
|
|
59
|
+
Dir.glob(File.join(EXAMPLES_PATH, '*.example.*')).sort
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def destination_for(src)
|
|
63
|
+
File.join(@target_path, target_name(src))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def target_name(src)
|
|
67
|
+
File.basename(src).sub('.example', '')
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -42,6 +42,18 @@ module Appydave
|
|
|
42
42
|
get('current_user')
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
+
# Path to the root brains directory (second-brain knowledge base)
|
|
46
|
+
# Configure via settings.json key: brains-root-path
|
|
47
|
+
def brains_root_path
|
|
48
|
+
get('brains-root-path')
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Path to the OMI wearable transcripts directory
|
|
52
|
+
# Configure via settings.json key: omi-directory-path
|
|
53
|
+
def omi_directory_path
|
|
54
|
+
get('omi-directory-path')
|
|
55
|
+
end
|
|
56
|
+
|
|
45
57
|
def print
|
|
46
58
|
log.subheading 'Settings Configuration'
|
|
47
59
|
|
|
@@ -54,6 +54,8 @@ module Appydave
|
|
|
54
54
|
run_remove(args)
|
|
55
55
|
when 'validate'
|
|
56
56
|
run_validate(args)
|
|
57
|
+
when 'query'
|
|
58
|
+
run_query(args)
|
|
57
59
|
when 'report'
|
|
58
60
|
run_report(args)
|
|
59
61
|
when 'generate'
|
|
@@ -128,6 +130,35 @@ module Appydave
|
|
|
128
130
|
exit_code_for(result)
|
|
129
131
|
end
|
|
130
132
|
|
|
133
|
+
def run_query(args)
|
|
134
|
+
meta_mode = args.delete('--meta')
|
|
135
|
+
|
|
136
|
+
find_terms = extract_multi_option(args, '--find')
|
|
137
|
+
type_filter = extract_option(args, '--type')
|
|
138
|
+
brand_filter = extract_option(args, '--brand')
|
|
139
|
+
args.delete('--path-only')
|
|
140
|
+
|
|
141
|
+
cmd = Commands::Query.new(
|
|
142
|
+
load_config,
|
|
143
|
+
find: find_terms,
|
|
144
|
+
type: type_filter,
|
|
145
|
+
brand: brand_filter
|
|
146
|
+
)
|
|
147
|
+
result = cmd.run
|
|
148
|
+
|
|
149
|
+
if result[:success]
|
|
150
|
+
if meta_mode
|
|
151
|
+
format_output(result, 'json')
|
|
152
|
+
else
|
|
153
|
+
format_output(result, 'paths')
|
|
154
|
+
end
|
|
155
|
+
else
|
|
156
|
+
warn result[:error]
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
exit_code_for(result)
|
|
160
|
+
end
|
|
161
|
+
|
|
131
162
|
def run_get(args)
|
|
132
163
|
format = format_option(args)
|
|
133
164
|
key = args.first
|
|
@@ -350,6 +381,20 @@ module Appydave
|
|
|
350
381
|
attrs
|
|
351
382
|
end
|
|
352
383
|
|
|
384
|
+
def extract_multi_option(args, flag)
|
|
385
|
+
values = []
|
|
386
|
+
loop do
|
|
387
|
+
index = args.index(flag)
|
|
388
|
+
break unless index
|
|
389
|
+
|
|
390
|
+
value = args[index + 1]
|
|
391
|
+
args.delete_at(index + 1)
|
|
392
|
+
args.delete_at(index)
|
|
393
|
+
values << value if value
|
|
394
|
+
end
|
|
395
|
+
values
|
|
396
|
+
end
|
|
397
|
+
|
|
353
398
|
def extract_option(args, flag)
|
|
354
399
|
index = args.index(flag)
|
|
355
400
|
return nil unless index
|
|
@@ -407,6 +452,7 @@ module Appydave
|
|
|
407
452
|
search <terms> Fuzzy search across all location metadata
|
|
408
453
|
get <key> Get location by exact key
|
|
409
454
|
list List all locations
|
|
455
|
+
query Scriptable location lookup (pipeline-friendly)
|
|
410
456
|
|
|
411
457
|
CRUD Operations:
|
|
412
458
|
add Add a new location
|
|
@@ -445,6 +491,8 @@ module Appydave
|
|
|
445
491
|
topic = args.first
|
|
446
492
|
|
|
447
493
|
case topic
|
|
494
|
+
when 'query'
|
|
495
|
+
show_query_help
|
|
448
496
|
when 'search'
|
|
449
497
|
show_search_help
|
|
450
498
|
when 'add'
|
|
@@ -464,6 +512,36 @@ module Appydave
|
|
|
464
512
|
end
|
|
465
513
|
end
|
|
466
514
|
|
|
515
|
+
def show_query_help
|
|
516
|
+
output.puts <<~HELP
|
|
517
|
+
jump query - Scriptable location lookup (pipeline-friendly)
|
|
518
|
+
|
|
519
|
+
Usage: jump query [--find <term>] [--type <type>] [--brand <brand>] [--path-only|--meta]
|
|
520
|
+
|
|
521
|
+
Filters (all are AND-combined):
|
|
522
|
+
--find <term> Match term against key, name, brand, type, tags, description
|
|
523
|
+
Repeat for AND logic: --find appydave --find ruby
|
|
524
|
+
--type <type> Filter by location type (e.g. tool, gem, product)
|
|
525
|
+
--brand <brand> Filter by brand (e.g. appydave, flivideo)
|
|
526
|
+
|
|
527
|
+
Output modes:
|
|
528
|
+
(default) One path per line — pipeable (same as --path-only)
|
|
529
|
+
--path-only Explicit path-per-line mode
|
|
530
|
+
--meta JSON array with key, path, description, type, brand, status
|
|
531
|
+
|
|
532
|
+
Exit codes:
|
|
533
|
+
0 Matches found
|
|
534
|
+
1 No matches (NOT_FOUND)
|
|
535
|
+
|
|
536
|
+
Examples:
|
|
537
|
+
jump query --find flivideo
|
|
538
|
+
jump query --find flivideo --meta
|
|
539
|
+
jump query --type tool
|
|
540
|
+
jump query --find appydave --type tool
|
|
541
|
+
jump query --find flivideo | xargs llm_context -b
|
|
542
|
+
HELP
|
|
543
|
+
end
|
|
544
|
+
|
|
467
545
|
def show_search_help
|
|
468
546
|
output.puts <<~HELP
|
|
469
547
|
jump search - Fuzzy search locations
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Appydave
|
|
4
|
+
module Tools
|
|
5
|
+
module Jump
|
|
6
|
+
module Commands
|
|
7
|
+
# Query command provides scriptable location lookup
|
|
8
|
+
#
|
|
9
|
+
# Designed for pipeline use — default output is bare paths, one per line.
|
|
10
|
+
# Use --meta for structured JSON output.
|
|
11
|
+
#
|
|
12
|
+
# @example Find by term
|
|
13
|
+
# cmd = Commands::Query.new(config, find: ['flivideo'])
|
|
14
|
+
# result = cmd.run
|
|
15
|
+
# result[:results] # => Array of matching location hashes
|
|
16
|
+
#
|
|
17
|
+
# @example Filter by type
|
|
18
|
+
# cmd = Commands::Query.new(config, type: 'product')
|
|
19
|
+
# result = cmd.run
|
|
20
|
+
class Query < Base
|
|
21
|
+
attr_reader :find_terms, :type_filter, :brand_filter
|
|
22
|
+
|
|
23
|
+
def initialize(config, **options)
|
|
24
|
+
super
|
|
25
|
+
@find_terms = Array(options[:find]).map { |t| t.to_s.downcase.strip }.reject(&:empty?)
|
|
26
|
+
@type_filter = normalize_option(options[:type])
|
|
27
|
+
@brand_filter = normalize_option(options[:brand])
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def run
|
|
31
|
+
matches = config.locations.select { |loc| matches?(loc) }
|
|
32
|
+
|
|
33
|
+
if matches.empty?
|
|
34
|
+
return error_result(
|
|
35
|
+
'No locations found matching the given criteria',
|
|
36
|
+
code: 'NOT_FOUND'
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
results = matches.map.with_index(1) do |location, index|
|
|
41
|
+
location_to_result(location, index)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
success_result(
|
|
45
|
+
count: results.size,
|
|
46
|
+
results: results
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def matches?(location)
|
|
53
|
+
return false if type_filter && location.type&.downcase != type_filter
|
|
54
|
+
return false if brand_filter && location.brand&.downcase != brand_filter
|
|
55
|
+
return false unless find_terms_match?(location)
|
|
56
|
+
|
|
57
|
+
true
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def find_terms_match?(location)
|
|
61
|
+
return true if find_terms.empty?
|
|
62
|
+
|
|
63
|
+
# All find terms must match (AND logic) — each term matches if it appears
|
|
64
|
+
# in any of the searchable fields of the location
|
|
65
|
+
find_terms.all? { |term| term_matches_location?(term, location) }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def term_matches_location?(term, location)
|
|
69
|
+
fields = [
|
|
70
|
+
location.key,
|
|
71
|
+
location.type,
|
|
72
|
+
location.brand,
|
|
73
|
+
location.client,
|
|
74
|
+
location.description,
|
|
75
|
+
location.path
|
|
76
|
+
].compact.map(&:downcase)
|
|
77
|
+
|
|
78
|
+
tag_fields = location.tags.map(&:downcase)
|
|
79
|
+
|
|
80
|
+
fields.any? { |f| f.include?(term) } || tag_fields.any? { |t| t.include?(term) }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def normalize_option(value)
|
|
84
|
+
return nil unless value
|
|
85
|
+
|
|
86
|
+
value.to_s.downcase.strip
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def location_to_result(location, index)
|
|
90
|
+
{
|
|
91
|
+
index: index,
|
|
92
|
+
key: location.key,
|
|
93
|
+
path: expand_path(location.path),
|
|
94
|
+
description: location.description,
|
|
95
|
+
type: location.type,
|
|
96
|
+
brand: location.brand,
|
|
97
|
+
client: location.client,
|
|
98
|
+
tags: location.tags,
|
|
99
|
+
status: 'active'
|
|
100
|
+
}.compact
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def expand_path(path)
|
|
104
|
+
return path unless path
|
|
105
|
+
|
|
106
|
+
File.expand_path(path)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|