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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +84 -0
- data/LICENSE +21 -0
- data/README.md +196 -0
- data/bin/architext +6 -0
- data/bin/setup +27 -0
- data/lib/architext/bundle.rb +20 -0
- data/lib/architext/cli.rb +444 -0
- data/lib/architext/clipboard.rb +58 -0
- data/lib/architext/obsidian.rb +84 -0
- data/lib/architext/picker.rb +39 -0
- data/lib/architext/search_results.rb +67 -0
- data/lib/architext/selection_parser.rb +32 -0
- data/lib/architext/settings.rb +40 -0
- data/lib/architext/terminal.rb +130 -0
- data/lib/architext/tui.rb +512 -0
- data/lib/architext/version.rb +5 -0
- data/lib/architext.rb +13 -0
- metadata +133 -0
|
@@ -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
|