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.
@@ -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
- @omi_dir = File.expand_path('~/dev/raw-intake/omi')
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 ||= File.expand_path('~/dev/ad/brains')
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