architext 0.2.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,444 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'io/console'
4
+ require 'optparse'
5
+ require 'shellwords'
6
+
7
+ require_relative 'bundle'
8
+ require_relative 'clipboard'
9
+ require_relative 'obsidian'
10
+ require_relative 'picker'
11
+ require_relative 'settings'
12
+ require_relative 'tui'
13
+ require_relative 'version'
14
+
15
+ module Architext
16
+ # rubocop:disable Metrics/ClassLength
17
+ class CLI
18
+ DEFAULT_QUERY = 'tag:#project/active'
19
+ VAULT_SOURCE_EXPLICIT = '--vault'
20
+ VAULT_SOURCE_SAVED_DEFAULT = 'saved default'
21
+ VAULT_SOURCE_SESSION = 'session'
22
+ VAULT_SOURCE_OBSIDIAN_DEFAULT = 'obsidian default'
23
+ SearchAttempt = Data.define(:paths, :next_query)
24
+
25
+ def initialize(argv, io: {}, app_name: 'architext', dependencies: {})
26
+ @stdin = io.fetch(:stdin, $stdin)
27
+ @stdout = io.fetch(:stdout, $stdout)
28
+ @stderr = io.fetch(:stderr, $stderr)
29
+ @clipboard = dependencies.fetch(:clipboard, Clipboard.new)
30
+ @settings = dependencies.fetch(:settings, Settings.new)
31
+ @argv = argv
32
+ @app_name = app_name
33
+ @options = {
34
+ query: nil,
35
+ vault: nil,
36
+ set_default_vault: nil,
37
+ clear_default_vault: false,
38
+ diagnose: false,
39
+ stdout: false,
40
+ dry_run: false,
41
+ all: false
42
+ }
43
+ @vault_source = VAULT_SOURCE_OBSIDIAN_DEFAULT
44
+ end
45
+
46
+ def run
47
+ parse_options
48
+ return 0 if handled_default_vault_options?
49
+
50
+ apply_default_vault
51
+ return run_diagnostics if @options[:diagnose]
52
+
53
+ selected_paths = gather_selection
54
+ return 1 unless selected_paths
55
+ return no_selection if selected_paths.empty?
56
+
57
+ if @options[:dry_run]
58
+ print_dry_run(selected_paths, Obsidian.new(vault: @options[:vault]))
59
+ return 0
60
+ end
61
+
62
+ files = read_selected_files(Obsidian.new(vault: @options[:vault]), selected_paths)
63
+ bundle = Bundle.new(files).to_markdown
64
+ write_output(bundle)
65
+ 0
66
+ rescue OptionParser::InvalidOption, OptionParser::MissingArgument => e
67
+ ui.show_error "#{@app_name}: #{e.message}"
68
+ @stderr.puts "Run `bin/#{@app_name} --help` for usage."
69
+ 2
70
+ rescue Obsidian::CommandNotFound => e
71
+ ui.show_error "#{@app_name}: #{e.message}"
72
+ @stderr.puts 'Install/enable Obsidian CLI, or set ARCHITEXT_OBSIDIAN to its path.'
73
+ 127
74
+ rescue Obsidian::CommandFailed => e
75
+ ui.show_error "#{@app_name}: #{e.message}"
76
+ 1
77
+ rescue Interrupt
78
+ @stderr.puts "\n#{@app_name}: cancelled"
79
+ 130
80
+ end
81
+
82
+ private
83
+
84
+ # rubocop:disable Metrics/BlockLength
85
+ def parse_options
86
+ parser = OptionParser.new do |opts|
87
+ opts.banner = "Usage: bin/#{@app_name} [options]"
88
+
89
+ opts.on('-q', '--query QUERY', "Obsidian search query. Default: #{DEFAULT_QUERY}") do |value|
90
+ @options[:query] = value
91
+ end
92
+
93
+ opts.on('-v', '--vault VAULT', 'Obsidian vault name or id') do |value|
94
+ @options[:vault] = value
95
+ end
96
+
97
+ opts.on('--set-default-vault VAULT', 'Set persistent default vault for future runs') do |value|
98
+ @options[:set_default_vault] = value
99
+ end
100
+
101
+ opts.on('--clear-default-vault', 'Clear persistent default vault') do
102
+ @options[:clear_default_vault] = true
103
+ end
104
+
105
+ opts.on('--diagnose', 'Print Obsidian CLI and vault diagnostics, then exit') do
106
+ @options[:diagnose] = true
107
+ end
108
+
109
+ opts.on('--stdout', 'Print stitched context instead of copying to clipboard') do
110
+ @options[:stdout] = true
111
+ end
112
+
113
+ opts.on('--dry-run', 'Show selected files and estimated bundle size') do
114
+ @options[:dry_run] = true
115
+ end
116
+
117
+ opts.on('--all', 'Include all search results without opening the picker') do
118
+ @options[:all] = true
119
+ end
120
+
121
+ opts.on('-h', '--help', 'Show this help') do
122
+ @stdout.puts opts
123
+ exit 0
124
+ end
125
+
126
+ opts.on('--version', 'Show version') do
127
+ @stdout.puts Architext::VERSION
128
+ exit 0
129
+ end
130
+ end
131
+
132
+ parser.parse!(@argv)
133
+ end
134
+ # rubocop:enable Metrics/BlockLength
135
+
136
+ def gather_selection
137
+ query = @options[:query] || prompt_for_query
138
+ return nil if query.nil?
139
+
140
+ vault = @options[:vault]
141
+ vault_source = @vault_source
142
+
143
+ loop do
144
+ client = Obsidian.new(vault:)
145
+ attempt = search_with_recovery(client, query)
146
+ if attempt.next_query
147
+ query = attempt.next_query
148
+ return nil if query.nil?
149
+
150
+ next
151
+ end
152
+
153
+ paths = attempt.paths
154
+ if paths.empty?
155
+ query = handle_no_results(query, vault, vault_source)
156
+ return nil if query.nil?
157
+
158
+ next
159
+ end
160
+
161
+ selection = select_paths(paths, query:, vault:, vault_source:)
162
+ if selection.new_vault
163
+ vault = selection.new_vault.strip
164
+ vault = nil if vault.empty?
165
+ @options[:vault] = vault
166
+ vault_source = vault.nil? ? VAULT_SOURCE_OBSIDIAN_DEFAULT : VAULT_SOURCE_SESSION
167
+ @vault_source = vault_source
168
+ next
169
+ end
170
+
171
+ if selection.reprompt_query
172
+ query = prompt_for_query
173
+
174
+ return nil if query.nil?
175
+
176
+ next
177
+ end
178
+
179
+ return selection.paths unless selection.new_query
180
+
181
+ query = selection.new_query
182
+ end
183
+ end
184
+
185
+ def search_with_recovery(client, query)
186
+ SearchAttempt.new(paths: client.search(query), next_query: nil)
187
+ rescue Obsidian::CommandFailed => e
188
+ if query_uses_operators?(query) && silent_exit_127?(e.message)
189
+ ui.show_info('Search operator query failed on this Obsidian CLI session. Retrying with plain-text fallback...')
190
+ fallback = operator_free_query(query)
191
+ retried = search_plain_fallback(client, fallback)
192
+ return retried if retried
193
+ end
194
+
195
+ next_query = handle_search_error(query, e)
196
+ return SearchAttempt.new(paths: [], next_query:) if interactive?
197
+
198
+ raise e
199
+ end
200
+
201
+ def handled_default_vault_options?
202
+ if @options[:set_default_vault]
203
+ vault = @options[:set_default_vault].to_s.strip
204
+ raise Obsidian::CommandFailed, 'default vault cannot be blank' if vault.empty?
205
+
206
+ @settings.default_vault = vault
207
+ @stdout.puts "Default vault set to: #{vault}"
208
+ return true
209
+ end
210
+
211
+ return false unless @options[:clear_default_vault]
212
+
213
+ @settings.clear_default_vault
214
+ @stdout.puts 'Default vault cleared.'
215
+ true
216
+ end
217
+
218
+ def apply_default_vault
219
+ if @options[:vault]
220
+ @vault_source = VAULT_SOURCE_EXPLICIT
221
+ return
222
+ end
223
+
224
+ default_vault = @settings.default_vault
225
+ @options[:vault] = default_vault
226
+ @vault_source = default_vault ? VAULT_SOURCE_SAVED_DEFAULT : VAULT_SOURCE_OBSIDIAN_DEFAULT
227
+ end
228
+
229
+ def prompt_for_query
230
+ return DEFAULT_QUERY unless interactive?
231
+
232
+ loop do
233
+ connection = build_connection_report
234
+ input = ui.prompt_query(
235
+ default: DEFAULT_QUERY,
236
+ context: {
237
+ vault: @options[:vault],
238
+ vault_source: @vault_source,
239
+ default_vault: @settings.default_vault,
240
+ default_vault_path: @settings.config_path,
241
+ connection_report: connection
242
+ }
243
+ )
244
+ return nil if input.quit
245
+
246
+ if input.open_vault_config
247
+ handle_prompt_vault_config
248
+ next
249
+ end
250
+
251
+ return input.query
252
+ end
253
+ end
254
+
255
+ def build_connection_report
256
+ report = {
257
+ executable: ENV.fetch('ARCHITEXT_OBSIDIAN', 'obsidian'),
258
+ status: 'unknown',
259
+ version: nil,
260
+ resolved_vault_summary: nil,
261
+ warning: nil
262
+ }
263
+ client = Obsidian.new(vault: @options[:vault], executable: report[:executable])
264
+ report[:version] = client.version
265
+ report[:resolved_vault_summary] = summarize_vault_info(client.vault_info)
266
+ report[:status] = 'ok'
267
+ report[:warning] = vault_mismatch_warning(report[:resolved_vault_summary], @options[:vault])
268
+ report
269
+ rescue Obsidian::CommandFailed => e
270
+ report[:status] = 'error'
271
+ report[:warning] = first_line(e.message)
272
+ report
273
+ rescue Obsidian::CommandNotFound => e
274
+ report[:status] = 'error'
275
+ report[:warning] = e.message
276
+ report
277
+ end
278
+
279
+ def run_diagnostics
280
+ report = build_connection_report
281
+ @stdout.puts "ARCHiTEXT diagnostics (v#{Architext::VERSION})"
282
+ @stdout.puts "active vault ref: #{@options[:vault] || '(none selected)'}"
283
+ @stdout.puts "vault source: #{@vault_source}"
284
+ @stdout.puts "saved default vault: #{@settings.default_vault || '(none)'}"
285
+ @stdout.puts "default vault config path: #{@settings.config_path}"
286
+ @stdout.puts "obsidian cli executable: #{report[:executable]}"
287
+ @stdout.puts "obsidian cli version: #{report[:version] || 'unknown'}"
288
+ @stdout.puts "connection check: #{report[:status]}"
289
+ @stdout.puts "resolved vault: #{report[:resolved_vault_summary] || '(unknown)'}"
290
+ @stdout.puts "diagnostic warning: #{report[:warning]}" if report[:warning]
291
+ report[:status] == 'ok' ? 0 : 1
292
+ end
293
+
294
+ def handle_no_results(query, vault, vault_source)
295
+ ui.show_no_results(
296
+ query,
297
+ vault:,
298
+ vault_source:,
299
+ default_vault_path: @settings.config_path,
300
+ obsidian_executable: ENV.fetch('ARCHITEXT_OBSIDIAN', 'obsidian')
301
+ )
302
+ interactive? ? prompt_for_query : nil
303
+ end
304
+
305
+ def handle_search_error(_query, error)
306
+ if interactive?
307
+ ui.show_error("#{error.message}\nReturning to search prompt.")
308
+ return prompt_for_query
309
+ end
310
+
311
+ raise error
312
+ end
313
+
314
+ def select_paths(paths, query:, vault:, vault_source:)
315
+ return TUI::Selection.new(paths:, new_query: nil, new_vault: nil, reprompt_query: false) if @options[:all]
316
+
317
+ unless interactive?
318
+ raise Obsidian::CommandFailed,
319
+ 'interactive selection requires a TTY; rerun with --all or provide input from a terminal'
320
+ end
321
+
322
+ ui.select(paths, query:, vault:, vault_source:)
323
+ end
324
+
325
+ def handle_prompt_vault_config
326
+ loop do
327
+ action = ui.prompt_vault_config(
328
+ active_vault: @options[:vault],
329
+ active_vault_source: @vault_source,
330
+ default_vault: @settings.default_vault,
331
+ default_vault_path: @settings.config_path
332
+ )
333
+ return if action.back
334
+
335
+ if action.clear_default
336
+ @settings.clear_default_vault
337
+ ui.show_info('Default vault cleared.')
338
+ next
339
+ end
340
+
341
+ if action.set_default_vault
342
+ @settings.default_vault = action.set_default_vault
343
+ ui.show_info("Default vault set to: #{action.set_default_vault}")
344
+ next
345
+ end
346
+
347
+ next unless action.session_vault
348
+
349
+ vault = action.session_vault.strip
350
+ vault = nil if vault.empty?
351
+ @options[:vault] = vault
352
+ @vault_source = vault.nil? ? VAULT_SOURCE_OBSIDIAN_DEFAULT : VAULT_SOURCE_SESSION
353
+ end
354
+ end
355
+
356
+ def print_dry_run(selected_paths, client)
357
+ files = read_selected_files(client, selected_paths)
358
+ bytes = Bundle.new(files).to_markdown.bytesize
359
+ ui.show_dry_run(selected_paths, bytes)
360
+ end
361
+
362
+ def read_selected_files(client, selected_paths)
363
+ selected_paths.map do |path|
364
+ FileContent.new(path:, content: client.read(path))
365
+ end
366
+ end
367
+
368
+ def write_output(bundle)
369
+ if @options[:stdout]
370
+ @stdout.write bundle
371
+ return
372
+ end
373
+
374
+ @clipboard.copy(bundle)
375
+ ui.show_copied(bundle.bytesize)
376
+ rescue Clipboard::Error => e
377
+ raise Obsidian::CommandFailed, "#{e.message}\nTip: rerun with --stdout to print the bundle."
378
+ end
379
+
380
+ def query_uses_operators?(query)
381
+ query.to_s.match?(/(^|\s)(tag|path|file|line):/i)
382
+ end
383
+
384
+ def silent_exit_127?(message)
385
+ message.to_s.match?(/exit\s+127/i)
386
+ end
387
+
388
+ def operator_free_query(query)
389
+ query.to_s.gsub(/(^|\s)[a-z]+:[^\s]+/i, ' ').gsub('#', ' ').strip
390
+ end
391
+
392
+ def search_plain_fallback(client, query)
393
+ return nil if query.empty?
394
+
395
+ SearchAttempt.new(paths: client.search(query), next_query: nil)
396
+ rescue Obsidian::CommandFailed
397
+ nil
398
+ end
399
+
400
+ def summarize_vault_info(text)
401
+ lines = text.to_s.lines.map(&:strip).reject(&:empty?)
402
+ kv = lines.each_with_object({}) do |line, memo|
403
+ next unless line.match?(/\A[a-zA-Z0-9_]+\s+/)
404
+
405
+ key, value = line.split(/\s+/, 2)
406
+ memo[key.downcase] = value
407
+ end
408
+
409
+ name = kv['name']
410
+ path = kv['path']
411
+ return "#{name} | #{path}" if name && path
412
+ return name if name
413
+ return path if path
414
+
415
+ compact = lines.join(' | ')
416
+ compact[0, 180]
417
+ end
418
+
419
+ def vault_mismatch_warning(vault_summary, requested_vault)
420
+ return nil if requested_vault.to_s.strip.empty?
421
+ return nil if vault_summary.to_s.downcase.include?(requested_vault.to_s.downcase)
422
+
423
+ "Requested vault '#{requested_vault}' may not match resolved vault reported by Obsidian CLI."
424
+ end
425
+
426
+ def first_line(text)
427
+ text.to_s.lines.first.to_s.strip
428
+ end
429
+
430
+ def no_selection
431
+ ui.show_no_selection
432
+ 1
433
+ end
434
+
435
+ def interactive?
436
+ @stdin.tty? && @stdout.tty?
437
+ end
438
+
439
+ def ui
440
+ @ui ||= TUI.new(stdin: @stdin, stdout: @stdout, stderr: @stderr, app_name: @app_name)
441
+ end
442
+ end
443
+ # rubocop:enable Metrics/ClassLength
444
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'rbconfig'
5
+
6
+ module Architext
7
+ class Clipboard
8
+ class Error < StandardError; end
9
+ class UnsupportedPlatform < Error; end
10
+ class CommandFailed < Error; end
11
+
12
+ def initialize(runner: Open3.method(:capture3), env: ENV, host_os: RbConfig::CONFIG['host_os'])
13
+ @runner = runner
14
+ @env = env
15
+ @host_os = host_os.to_s.downcase
16
+ end
17
+
18
+ def copy(text)
19
+ copied = false
20
+
21
+ candidates.each do |command|
22
+ _out, err, status = @runner.call(*command, stdin_data: text.to_s)
23
+ if status.success?
24
+ copied = true
25
+ break
26
+ end
27
+
28
+ details = err.to_s.strip
29
+ details = "exit #{status.exitstatus}" if details.empty?
30
+ raise CommandFailed, "Clipboard command failed: #{command.join(' ')} (#{details})"
31
+ rescue Errno::ENOENT
32
+ next
33
+ end
34
+
35
+ return if copied
36
+
37
+ raise UnsupportedPlatform,
38
+ 'No supported clipboard command found. Use --stdout to print and pipe to your clipboard tool.'
39
+ end
40
+
41
+ private
42
+
43
+ def candidates
44
+ return [%w[pbcopy]] if mac?
45
+ return [%w[clip], %w[powershell -NoProfile -Command Set-Clipboard]] if windows?
46
+
47
+ [%w[wl-copy], %w[xclip -selection clipboard], %w[xsel --clipboard --input]]
48
+ end
49
+
50
+ def mac?
51
+ @host_os.include?('darwin')
52
+ end
53
+
54
+ def windows?
55
+ @host_os.match?(/mswin|mingw|cygwin/)
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'shellwords'
5
+
6
+ require_relative 'search_results'
7
+
8
+ module Architext
9
+ class Obsidian
10
+ class CommandFailed < StandardError; end
11
+ class CommandNotFound < CommandFailed; end
12
+
13
+ def initialize(vault: nil, executable: ENV.fetch('ARCHITEXT_OBSIDIAN', 'obsidian'))
14
+ @vault = vault
15
+ @executable = executable
16
+ end
17
+
18
+ def search(query)
19
+ json_output = run('search', "query=#{query}", 'format=json')
20
+ json_paths = SearchResults.parse(json_output)
21
+ return json_paths unless json_paths.empty?
22
+
23
+ text_output = run('search', "query=#{query}", 'format=text')
24
+ SearchResults.parse(text_output)
25
+ end
26
+
27
+ def read(path)
28
+ run('read', "path=#{path}")
29
+ end
30
+
31
+ def version
32
+ run('version').to_s.strip
33
+ end
34
+
35
+ def vault_info
36
+ run('vault').to_s
37
+ end
38
+
39
+ private
40
+
41
+ def run(*args)
42
+ command = [@executable]
43
+ command << "vault=#{@vault}" if @vault && !@vault.empty?
44
+ command.concat(args)
45
+
46
+ stdout, stderr, status = Open3.capture3(*capture_command(command))
47
+ stdout = normalize_output(stdout)
48
+ stderr = normalize_output(stderr)
49
+ return stdout if status.success?
50
+
51
+ if status.exitstatus == 127 || stderr.match?(/not found|no such file/i)
52
+ raise CommandNotFound, "Obsidian CLI executable not found: #{@executable}"
53
+ end
54
+
55
+ rendered = command.shelljoin
56
+ details = stderr.strip.empty? ? "exit #{status.exitstatus}" : stderr.strip
57
+ raise CommandFailed, "Obsidian command failed: #{rendered}\n#{details}"
58
+ rescue Errno::ENOENT
59
+ raise CommandNotFound, "Obsidian CLI executable not found: #{@executable}"
60
+ end
61
+
62
+ def normalize_output(output)
63
+ text = output.to_s.dup
64
+ text.force_encoding(Encoding::UTF_8)
65
+ text.valid_encoding? ? text : text.scrub
66
+ end
67
+
68
+ def capture_command(command)
69
+ return command unless Gem.win_platform?
70
+
71
+ ['powershell', '-NoProfile', '-NonInteractive', '-Command', powershell_invocation(command)]
72
+ end
73
+
74
+ def powershell_invocation(command)
75
+ escaped_command = command.map { |part| powershell_quote(part) }.join(' ')
76
+
77
+ "$ErrorActionPreference = 'Stop'; & #{escaped_command}; exit $LASTEXITCODE"
78
+ end
79
+
80
+ def powershell_quote(value)
81
+ "'#{value.to_s.gsub("'", "''")}'"
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'selection_parser'
4
+
5
+ module Architext
6
+ class Picker
7
+ def initialize(stdin:, stdout:)
8
+ @stdin = stdin
9
+ @stdout = stdout
10
+ end
11
+
12
+ def select(paths)
13
+ tty_prompt_select(paths) || fallback_select(paths)
14
+ end
15
+
16
+ private
17
+
18
+ def tty_prompt_select(paths)
19
+ require 'tty-prompt'
20
+
21
+ prompt = TTY::Prompt.new(input: @stdin, output: @stdout)
22
+ prompt.multi_select('Select notes to include:', paths, per_page: 20)
23
+ rescue LoadError
24
+ nil
25
+ end
26
+
27
+ def fallback_select(paths)
28
+ @stdout.puts 'Select notes to include:'
29
+ paths.each_with_index do |path, index|
30
+ @stdout.puts "#{index + 1}. #{path}"
31
+ end
32
+
33
+ @stdout.puts
34
+ @stdout.print 'Enter numbers, ranges, or all (example: 1,3-5): '
35
+ input = @stdin.gets&.strip.to_s
36
+ SelectionParser.new(paths).parse(input)
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Architext
6
+ class SearchResults
7
+ def self.parse(output)
8
+ new(output).parse
9
+ end
10
+
11
+ def initialize(output)
12
+ @output = normalize_output(output)
13
+ end
14
+
15
+ def parse
16
+ parsed = JSON.parse(@output)
17
+ paths_from_json(parsed).uniq
18
+ rescue JSON::ParserError
19
+ paths_from_text(@output).uniq
20
+ end
21
+
22
+ private
23
+
24
+ def paths_from_json(value)
25
+ paths = case value
26
+ when Array
27
+ value.flat_map { |entry| paths_from_json(entry) }
28
+ when Hash
29
+ [hash_path(value), *paths_from_nested_hash(value)]
30
+ when String
31
+ [clean_path(value)]
32
+ else
33
+ []
34
+ end
35
+
36
+ paths.compact
37
+ end
38
+
39
+ def paths_from_nested_hash(hash)
40
+ %w[results matches files items].flat_map do |key|
41
+ paths_from_json(hash[key] || hash[key.to_sym])
42
+ end
43
+ end
44
+
45
+ def hash_path(hash)
46
+ path = hash['path'] || hash[:path] || hash['file'] || hash[:file]
47
+ clean_path(path) if path
48
+ end
49
+
50
+ def paths_from_text(text)
51
+ text.lines.filter_map { |line| clean_path(line) }
52
+ end
53
+
54
+ def clean_path(value)
55
+ path = value.to_s.strip
56
+ return nil if path.empty?
57
+
58
+ path
59
+ end
60
+
61
+ def normalize_output(output)
62
+ text = output.to_s.dup
63
+ text.force_encoding(Encoding::UTF_8)
64
+ text.valid_encoding? ? text : text.scrub
65
+ end
66
+ end
67
+ end