architext 0.2.0 → 1.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f01f07f6d9ef1e104d4c8f5dc9575faf85864ec4a16e26e9776033e6bfb9c0df
4
- data.tar.gz: 9b3abb2535fe6d4145173a2b6d4a8dfb9d4857f5dba6f13e3d0dd484e67ac15f
3
+ metadata.gz: 98ba60f856ccd4030ef1c0bbedc1cdef3ee2e1cb4fbfd2bbbf6208c0360123a3
4
+ data.tar.gz: 2fd7551d931e0a96a700cd78df0b9ed88cc301fb209ad09a2f32b3cfea504976
5
5
  SHA512:
6
- metadata.gz: ba45236c8562e14652ff0b1404e5ca44b6b1f485f11fd23d288bac35a61260437abd4fc72e13f95e0e91385ca95d6342912ad14a7c9730ee80ba6d11f1271458
7
- data.tar.gz: 91a02ffeb556817e1161e3560885591a7cf917f92e7e0ff8e11c99ab8f823e0c2889cc80ec4a853b083473c0691af7056265d8b2d37824918294146b07f5fa7b
6
+ metadata.gz: f4f6c4be6b57fa857338eb2abbab2208d16e011cda8cc47313fd6b9c55beb906cec5a57dc4f8396406a9a635471c22076366b2c1ec98db2af428954c83c6aa0a
7
+ data.tar.gz: 774bb942a60538631c6561947dd4bd282d787d2558f8a8b158a441bfabfd5854dcc1b2401cbf3cd3890e8d2f2ee8076a08e865f687d9dd53f349d73a771b53fd
data/CHANGELOG.md CHANGED
@@ -3,7 +3,19 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
- and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v1.0.0.html).
7
+
8
+ ## [1.0.0] - 2026-05-29
9
+
10
+ ### Added
11
+ - Native markdown folder source is now the default, searching `.md` and `.markdown` files under the current directory or `--root`.
12
+ - `--source native|obsidian` and `--root PATH` flags for explicit source selection.
13
+ - Native search support for plain terms, quoted phrases, `tag:`, `path:`, and `file:` query tokens.
14
+
15
+ ### Changed
16
+ - Obsidian CLI integration is now optional and explicit via `--source obsidian` or legacy `--vault`.
17
+ - TUI and diagnostics now use source/root language and only show Obsidian CLI details in Obsidian mode.
18
+ - README and gem metadata now position ARCHiTEXT as a markdown context stitching tool.
7
19
 
8
20
  ## [0.2.0] - 2026-05-16
9
21
 
data/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  <p align="center">
4
4
  <img src="assets/logo.svg" alt="ARCHiTEXT Logo" width="200">
5
5
  <br>
6
- <b>The bridge between your Obsidian vault and LLMs.</b>
6
+ <b>The bridge between markdown notes and LLMs.</b>
7
7
  <br>
8
8
  <i>A high-performance Ruby TUI for context stitching.</i>
9
9
  </p>
@@ -12,20 +12,42 @@
12
12
  <a href="https://github.com/CanPixel/ARCHiTEXT/actions/workflows/ci.yml">
13
13
  <img src="https://github.com/CanPixel/ARCHiTEXT/actions/workflows/ci.yml/badge.svg" alt="CI Status">
14
14
  </a>
15
+ <img src="https://img.shields.io/badge/maintained-yes-green.svg" alt="Maintained">
16
+ <a href="https://clickgems.clickhouse.com/dashboard/architext">
17
+ <img src="https://img.shields.io/gem/dt/architext" alt="RubyGem Downloads">
18
+ </a>
19
+ <a href="https://rubygems.org/gems/architext">
20
+ <img src="https://img.shields.io/github/stars/CanPixel" alt="GitHub Stars">
21
+ </a>
22
+ </p>
23
+
24
+ <p align="center">
25
+ <img src="https://img.shields.io/badge/Ruby-CC342D?style=flat&logo=ruby&logoColor=white" alt="Ruby">
15
26
  <a href="https://rubygems.org/gems/architext">
16
- <img src="https://img.shields.io/badge/version-v0.2.0-5BE8B8.svg" alt="Version 0.2.0">
27
+ <img src="https://img.shields.io/gem/v/architext" alt="License">
28
+ </a>
29
+ <a href="https://rubygems.org/gems/architext">
30
+ <img src="https://img.shields.io/badge/version-v1.0.0-5BE8B8.svg" alt="Version 1.0.0">
17
31
  </a>
18
32
  <a href="LICENSE">
19
33
  <img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License">
20
34
  </a>
21
- <img src="https://img.shields.io/badge/maintained-yes-green.svg" alt="Maintained">
22
35
  </p>
23
36
 
24
37
  ---
25
38
 
39
+ <img src="assets/title.svg" alt="ARCHiTEXT Text Logo">
40
+
26
41
  <img src="assets/social-preview.svg" alt="ARCHiTEXT Social Preview" width="1080">
27
42
 
28
- `ARCHiTEXT` is a standalone Ruby TUI for stitching Obsidian notes into an LLM-friendly Markdown context bundle. It uses the official `obsidian` CLI as the source of truth for vault search and file reads, providing a visual terminal interface for selecting notes.
43
+
44
+ As LLM context windows grow, the way we feed them data **needs to scale**.
45
+
46
+ Modern AI agents thrive on context, but gathering that context manually from sprawling markdown folders is tedious. ARCHiTEXT transforms this process into a seamless, visual experience. It allows you to search, filter, and "stitch" multiple notes into a single, structured Markdown bundle that agents can consume immediately.
47
+
48
+ # Welcome to ARCHiTEXT!
49
+
50
+ `ARCHiTEXT` is a standalone Ruby TUI for stitching markdown notes into an LLM-friendly context bundle. By default it searches the current folder recursively for `.md` and `.markdown` files, while still offering an optional Obsidian CLI source for users who want vault-name resolution.
29
51
 
30
52
  ## 🚀 Quick Start
31
53
 
@@ -34,7 +56,7 @@
34
56
  architext
35
57
  ```
36
58
 
37
- ARCHiTEXT will detect your active Obsidian vault, ask for a search query, then open a picker where you can select notes and copy the stitched context bundle to your clipboard.
59
+ ARCHiTEXT will search markdown files in the current folder, ask for a search query, then open a picker where you can select notes and copy the stitched context bundle to your clipboard.
38
60
 
39
61
  Common next steps:
40
62
 
@@ -42,17 +64,21 @@ Common next steps:
42
64
  # Search with a specific query
43
65
  architext --query "game"
44
66
 
45
- # Search a specific vault
46
- architext --vault "Main Vault" --query "project"
67
+ # Search a specific folder
68
+ architext --root ~/Notes --query "project"
47
69
 
48
70
  # Print the bundle instead of copying it
49
71
  architext --query "tag:#project/active" --all --stdout
72
+
73
+ # Use Obsidian CLI mode explicitly
74
+ architext --source obsidian --vault "Main Vault" --query "Ideas"
50
75
  ```
51
76
 
52
77
  ## ✨ Features
53
78
 
54
79
  - **Visual Picker:** Interactively select notes using a high-performance ANSI TUI.
55
- - **Vault-Aware UX:** Active vault is always visible in the TUI, with inline vault switching.
80
+ - **Source-Aware UX:** Active folder or Obsidian vault is always visible in the TUI, with inline source switching.
81
+ - **Native Markdown Search:** Search any folder containing `.md` or `.markdown` files without opening Obsidian.
56
82
  - **Context Stitching:** Automatically bundles selected notes into a single Markdown file.
57
83
  - **LLM Ready:** Output is formatted specifically for easy consumption by AI agents.
58
84
  - **No Dependencies:** Built-in ANSI-based TUI logic (no complex visual gems required).
@@ -65,6 +91,11 @@ architext --query "tag:#project/active" --all --stdout
65
91
  ```sh
66
92
  gem install architext
67
93
  architext --version
94
+ ```
95
+
96
+ ### Uninstall
97
+
98
+ ```sh
68
99
  gem uninstall architext
69
100
  ```
70
101
 
@@ -81,7 +112,9 @@ gem install ./architext-*.gem
81
112
 
82
113
  ## ⚙️ Configuration
83
114
 
84
- Architext relies on the [Obsidian CLI](https://github.com/obsidianmd/obsidian-cli). Ensure it is installed and available on your `PATH`.
115
+ Architext does not require Obsidian for normal use. Native mode searches markdown files directly under the current folder or a folder passed with `--root`.
116
+
117
+ Obsidian CLI integration remains available as an optional source. Use it when you want Obsidian vault-name or vault-id resolution.
85
118
 
86
119
  ### Supported Platforms
87
120
 
@@ -89,9 +122,9 @@ Architext relies on the [Obsidian CLI](https://github.com/obsidianmd/obsidian-cl
89
122
  - **Windows:** Native terminal support (PowerShell/Windows Terminal) with `clip` clipboard integration.
90
123
  - **Linux:** Native terminal support with clipboard integration via `wl-copy`, `xclip`, or `xsel`.
91
124
 
92
- ### Setting the Obsidian CLI Path
125
+ ### Optional Obsidian CLI Path
93
126
 
94
- If the CLI is not in your `PATH`, you can set the `ARCHITEXT_OBSIDIAN` environment variable:
127
+ If you use `--source obsidian` and the CLI is not in your `PATH`, set the `ARCHITEXT_OBSIDIAN` environment variable:
95
128
 
96
129
  ```sh
97
130
  export ARCHITEXT_OBSIDIAN="/path/to/obsidian"
@@ -112,45 +145,60 @@ architext
112
145
  # Search with a specific query
113
146
  architext --query "Zombie Parkour"
114
147
 
148
+ # Search a specific markdown folder
149
+ architext --root ~/Notes --query "project"
150
+
115
151
  # Output directly to stdout (useful for piping)
116
- architext --query "tag:#ctx/current" --stdout | gemini "Summarize this"
152
+ architext --query "tag:#ctx/current" --all --stdout | gemini "Summarize this"
153
+
154
+ # Use Obsidian CLI mode with a specific vault
155
+ architext --source obsidian --vault "Main Vault" --query "Ideas"
117
156
 
118
- # Specify a vault
157
+ # Legacy shorthand: --vault implies --source obsidian
119
158
  architext --vault "Main Vault" --query "Ideas"
120
159
 
121
- # Set a persistent default vault
160
+ # Set a persistent default Obsidian vault
122
161
  architext --set-default-vault "Main Vault"
123
162
 
124
- # Clear the persistent default vault
163
+ # Clear the persistent default Obsidian vault
125
164
  architext --clear-default-vault
126
165
 
127
166
  # Check exactly which executable version is running
128
167
  architext --version
129
168
 
130
- # Print vault/CLI diagnostics before searching
169
+ # Print source diagnostics before searching
131
170
  architext --diagnose
132
171
 
133
172
  # Skip the picker and include all results
134
173
  architext --query "tag:#project/active" --all --stdout
135
174
  ```
136
175
 
137
- ### Vault Selection Behavior
176
+ ### Source Selection Behavior
177
+
178
+ - Native mode is the default and uses the current working directory as the markdown root.
179
+ - `--root PATH` sets the native markdown root for the current run.
180
+ - `--source native|obsidian` chooses the source explicitly.
181
+ - `--vault` sets the Obsidian vault only for the current run and implies `--source obsidian`.
182
+ - `--set-default-vault` stores a persistent default vault for future Obsidian-mode runs.
183
+ - At the search prompt, type `v` to open source configuration mode.
184
+ - Press `v` in the TUI selection screen to change source inline for the current session.
185
+ - Startup diagnostics show native root and markdown file count, or Obsidian CLI details when Obsidian mode is active.
138
186
 
139
- - `--vault` sets the vault only for the current run.
140
- - `--set-default-vault` stores a persistent default vault for future runs.
141
- - If `--vault` is omitted, the persistent default vault is used when available.
142
- - At the search prompt, type `v` to open vault configuration mode.
143
- - Press `v` in the TUI selection screen to change vault inline for the current session.
144
- - The active vault is shown in the TUI header (`vault: ...`) so target scope is always visible.
145
- - Vault path resolution is performed by the Obsidian CLI (`vault=<name_or_id>`); ARCHiTEXT displays the vault reference and source.
146
- - Startup prompt includes an Obsidian connection check (`version`, resolved vault summary, executable path).
187
+ ### Native Search Syntax
188
+
189
+ - Empty query lists all markdown files under the active root.
190
+ - Plain terms match case-insensitively against relative paths or file content.
191
+ - Quoted phrases match literally.
192
+ - `tag:foo` and `tag:#foo` match markdown tags such as `#foo` and nested tags such as `#foo/bar`.
193
+ - `path:term` and `file:term` match relative paths.
194
+ - Multiple tokens are AND-matched.
147
195
 
148
196
  ### Search Prompt Controls
149
197
 
150
198
  | Input | Action |
151
199
  | --- | --- |
152
200
  | `enter` | Run search with default query |
153
- | `v` | Open vault configuration screen |
201
+ | `v` | Open source configuration screen |
154
202
  | `q` | Quit |
155
203
 
156
204
  ### TUI Controls
@@ -161,27 +209,36 @@ architext --query "tag:#project/active" --all --stdout
161
209
  | `space` | Toggle selection for current note |
162
210
  | `a` | Toggle all visible notes |
163
211
  | `/` | Filter current results |
164
- | `n` | Start a new Obsidian search |
165
- | `v` | Set/change active vault |
212
+ | `n` | Start a new markdown search |
213
+ | `v` | Set/change active source |
166
214
  | `enter` | Confirm and bundle selected notes |
167
215
  | `q` | Return to search prompt |
168
216
 
169
217
  ## 🔍 Troubleshooting
170
218
 
171
219
  ### Obsidian CLI Not Found
172
- If you see an error about `obsidian` command not found:
220
+ This only applies when running with `--source obsidian` or `--vault`. If you see an error about `obsidian` command not found:
173
221
  1. Ensure the Obsidian desktop app is installed.
174
222
  2. Verify that you have installed the `obsidian` CLI tool.
175
223
  3. Check if `obsidian` is in your `PATH` by running `which obsidian` (macOS/Linux) or `where obsidian` (Windows).
176
224
  4. If you previously exported `OBSCTX_OBSIDIAN`, rename it to `ARCHITEXT_OBSIDIAN`.
177
225
 
226
+ ### Command Not Found After Gem Install
227
+ If `gem install architext` succeeds but `architext` is not found, your Ruby executable directory is not on your shell `PATH`.
228
+
229
+ 1. Find RubyGems executable directory:
230
+ - `gem env | grep "EXECUTABLE DIRECTORY"`
231
+ 2. Add that directory to your shell `PATH` (for `zsh`, update `~/.zshrc`).
232
+ 3. Restart your shell and verify:
233
+ - `which architext`
234
+ - `architext --version`
235
+
178
236
  ### No Results But Notes Exist
179
- - Verify the active vault shown in the TUI header.
180
- - At the search prompt, type `v` to open vault configuration.
181
- - In selection, use `v` to switch vault and rerun the query.
182
- - Set a persistent vault with `--set-default-vault "Your Vault"`.
183
- - For one-off runs, pass `--vault "Your Vault"` explicitly.
184
- - Run `architext --diagnose` to print the exact vault reference/source and Obsidian CLI resolution before searching.
237
+ - Verify the active source shown in the TUI header.
238
+ - In native mode, confirm the current folder or `--root` contains markdown files.
239
+ - At the search prompt, type `v` to open source configuration.
240
+ - In selection, use `v` to switch root or Obsidian vault and rerun the query.
241
+ - Run `architext --diagnose` to print the active source, native root, markdown count, and Obsidian details when applicable.
185
242
 
186
243
  ### Clipboard Issues
187
244
  Architext auto-detects clipboard tools:
data/lib/architext/cli.rb CHANGED
@@ -2,13 +2,13 @@
2
2
 
3
3
  require 'io/console'
4
4
  require 'optparse'
5
- require 'shellwords'
6
5
 
7
6
  require_relative 'bundle'
8
7
  require_relative 'clipboard'
9
8
  require_relative 'obsidian'
10
9
  require_relative 'picker'
11
10
  require_relative 'settings'
11
+ require_relative 'sources'
12
12
  require_relative 'tui'
13
13
  require_relative 'version'
14
14
 
@@ -16,10 +16,15 @@ module Architext
16
16
  # rubocop:disable Metrics/ClassLength
17
17
  class CLI
18
18
  DEFAULT_QUERY = 'tag:#project/active'
19
+ SOURCE_NATIVE = 'native'
20
+ SOURCE_OBSIDIAN = 'obsidian'
19
21
  VAULT_SOURCE_EXPLICIT = '--vault'
20
22
  VAULT_SOURCE_SAVED_DEFAULT = 'saved default'
21
23
  VAULT_SOURCE_SESSION = 'session'
22
24
  VAULT_SOURCE_OBSIDIAN_DEFAULT = 'obsidian default'
25
+ ROOT_SOURCE_EXPLICIT = '--root'
26
+ ROOT_SOURCE_CWD = 'current folder'
27
+ ROOT_SOURCE_SESSION = 'session'
23
28
  SearchAttempt = Data.define(:paths, :next_query)
24
29
 
25
30
  def initialize(argv, io: {}, app_name: 'architext', dependencies: {})
@@ -32,6 +37,8 @@ module Architext
32
37
  @app_name = app_name
33
38
  @options = {
34
39
  query: nil,
40
+ source: SOURCE_NATIVE,
41
+ root: Dir.pwd,
35
42
  vault: nil,
36
43
  set_default_vault: nil,
37
44
  clear_default_vault: false,
@@ -41,6 +48,7 @@ module Architext
41
48
  all: false
42
49
  }
43
50
  @vault_source = VAULT_SOURCE_OBSIDIAN_DEFAULT
51
+ @root_source = ROOT_SOURCE_CWD
44
52
  end
45
53
 
46
54
  def run
@@ -48,18 +56,20 @@ module Architext
48
56
  return 0 if handled_default_vault_options?
49
57
 
50
58
  apply_default_vault
59
+ normalize_source_options
51
60
  return run_diagnostics if @options[:diagnose]
52
61
 
53
62
  selected_paths = gather_selection
54
63
  return 1 unless selected_paths
55
64
  return no_selection if selected_paths.empty?
56
65
 
66
+ source = build_source
57
67
  if @options[:dry_run]
58
- print_dry_run(selected_paths, Obsidian.new(vault: @options[:vault]))
68
+ print_dry_run(selected_paths, source)
59
69
  return 0
60
70
  end
61
71
 
62
- files = read_selected_files(Obsidian.new(vault: @options[:vault]), selected_paths)
72
+ files = read_selected_files(source, selected_paths)
63
73
  bundle = Bundle.new(files).to_markdown
64
74
  write_output(bundle)
65
75
  0
@@ -71,7 +81,7 @@ module Architext
71
81
  ui.show_error "#{@app_name}: #{e.message}"
72
82
  @stderr.puts 'Install/enable Obsidian CLI, or set ARCHITEXT_OBSIDIAN to its path.'
73
83
  127
74
- rescue Obsidian::CommandFailed => e
84
+ rescue Obsidian::CommandFailed, SourceError => e
75
85
  ui.show_error "#{@app_name}: #{e.message}"
76
86
  1
77
87
  rescue Interrupt
@@ -86,12 +96,25 @@ module Architext
86
96
  parser = OptionParser.new do |opts|
87
97
  opts.banner = "Usage: bin/#{@app_name} [options]"
88
98
 
89
- opts.on('-q', '--query QUERY', "Obsidian search query. Default: #{DEFAULT_QUERY}") do |value|
99
+ opts.on('-q', '--query QUERY', "Markdown search query. Default: #{DEFAULT_QUERY}") do |value|
90
100
  @options[:query] = value
91
101
  end
92
102
 
103
+ opts.on('--source SOURCE', 'Search source: native or obsidian. Default: native') do |value|
104
+ source = value.to_s.strip.downcase
105
+ raise OptionParser::InvalidArgument, '--source must be native or obsidian' unless valid_source?(source)
106
+
107
+ @options[:source] = source
108
+ end
109
+
110
+ opts.on('--root PATH', 'Native markdown root. Default: current directory') do |value|
111
+ @options[:root] = value
112
+ @root_source = ROOT_SOURCE_EXPLICIT
113
+ end
114
+
93
115
  opts.on('-v', '--vault VAULT', 'Obsidian vault name or id') do |value|
94
116
  @options[:vault] = value
117
+ @options[:source] = SOURCE_OBSIDIAN
95
118
  end
96
119
 
97
120
  opts.on('--set-default-vault VAULT', 'Set persistent default vault for future runs') do |value|
@@ -102,7 +125,7 @@ module Architext
102
125
  @options[:clear_default_vault] = true
103
126
  end
104
127
 
105
- opts.on('--diagnose', 'Print Obsidian CLI and vault diagnostics, then exit') do
128
+ opts.on('--diagnose', 'Print source diagnostics, then exit') do
106
129
  @options[:diagnose] = true
107
130
  end
108
131
 
@@ -133,16 +156,17 @@ module Architext
133
156
  end
134
157
  # rubocop:enable Metrics/BlockLength
135
158
 
159
+ def valid_source?(source)
160
+ [SOURCE_NATIVE, SOURCE_OBSIDIAN].include?(source)
161
+ end
162
+
136
163
  def gather_selection
137
164
  query = @options[:query] || prompt_for_query
138
165
  return nil if query.nil?
139
166
 
140
- vault = @options[:vault]
141
- vault_source = @vault_source
142
-
143
167
  loop do
144
- client = Obsidian.new(vault:)
145
- attempt = search_with_recovery(client, query)
168
+ source = build_source
169
+ attempt = search_with_recovery(source, query)
146
170
  if attempt.next_query
147
171
  query = attempt.next_query
148
172
  return nil if query.nil?
@@ -152,19 +176,15 @@ module Architext
152
176
 
153
177
  paths = attempt.paths
154
178
  if paths.empty?
155
- query = handle_no_results(query, vault, vault_source)
179
+ query = handle_no_results(query, source.diagnostics)
156
180
  return nil if query.nil?
157
181
 
158
182
  next
159
183
  end
160
184
 
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
185
+ selection = select_paths(paths, query:, diagnostics: source.diagnostics)
186
+ if selection.source_config
187
+ handle_prompt_source_config
168
188
  next
169
189
  end
170
190
 
@@ -216,6 +236,8 @@ module Architext
216
236
  end
217
237
 
218
238
  def apply_default_vault
239
+ return unless @options[:source] == SOURCE_OBSIDIAN
240
+
219
241
  if @options[:vault]
220
242
  @vault_source = VAULT_SOURCE_EXPLICIT
221
243
  return
@@ -226,6 +248,14 @@ module Architext
226
248
  @vault_source = default_vault ? VAULT_SOURCE_SAVED_DEFAULT : VAULT_SOURCE_OBSIDIAN_DEFAULT
227
249
  end
228
250
 
251
+ def normalize_source_options
252
+ @options[:root] = File.expand_path(@options[:root].to_s)
253
+ return unless @options[:source] == SOURCE_NATIVE
254
+
255
+ @options[:vault] = nil
256
+ @vault_source = VAULT_SOURCE_OBSIDIAN_DEFAULT
257
+ end
258
+
229
259
  def prompt_for_query
230
260
  return DEFAULT_QUERY unless interactive?
231
261
 
@@ -234,6 +264,9 @@ module Architext
234
264
  input = ui.prompt_query(
235
265
  default: DEFAULT_QUERY,
236
266
  context: {
267
+ source: @options[:source],
268
+ root: @options[:root],
269
+ root_source: @root_source,
237
270
  vault: @options[:vault],
238
271
  vault_source: @vault_source,
239
272
  default_vault: @settings.default_vault,
@@ -244,7 +277,7 @@ module Architext
244
277
  return nil if input.quit
245
278
 
246
279
  if input.open_vault_config
247
- handle_prompt_vault_config
280
+ handle_prompt_source_config
248
281
  next
249
282
  end
250
283
 
@@ -253,49 +286,54 @@ module Architext
253
286
  end
254
287
 
255
288
  def build_connection_report
256
- report = {
257
- executable: ENV.fetch('ARCHITEXT_OBSIDIAN', 'obsidian'),
258
- status: 'unknown',
289
+ diagnostics = build_source.diagnostics
290
+ warning = diagnostics.warning
291
+ warning ||= vault_mismatch_warning(diagnostics.resolved_vault_summary, @options[:vault]) if diagnostics.source == SOURCE_OBSIDIAN
292
+ diagnostics.to_h.merge(warning:)
293
+ rescue SourceError => e
294
+ SourceDiagnostics.new(
295
+ source: @options[:source],
296
+ root: @options[:root],
297
+ vault: @options[:vault],
298
+ vault_source: @vault_source,
299
+ status: 'error',
300
+ warning: e.message,
301
+ markdown_count: nil,
302
+ executable: nil,
259
303
  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
304
+ resolved_vault_summary: nil
305
+ ).to_h
277
306
  end
278
307
 
279
308
  def run_diagnostics
280
309
  report = build_connection_report
281
310
  @stdout.puts "ARCHiTEXT diagnostics (v#{Architext::VERSION})"
311
+ @stdout.puts "active source: #{report[:source]}"
312
+ report[:source] == SOURCE_OBSIDIAN ? print_obsidian_diagnostics(report) : print_native_diagnostics(report)
313
+ @stdout.puts "connection check: #{report[:status]}"
314
+ @stdout.puts "diagnostic warning: #{report[:warning]}" if report[:warning]
315
+ report[:status] == 'ok' ? 0 : 1
316
+ end
317
+
318
+ def print_native_diagnostics(report)
319
+ @stdout.puts "root path: #{report[:root]}"
320
+ @stdout.puts "markdown files: #{report[:markdown_count] || 'unknown'}"
321
+ end
322
+
323
+ def print_obsidian_diagnostics(report)
282
324
  @stdout.puts "active vault ref: #{@options[:vault] || '(none selected)'}"
283
325
  @stdout.puts "vault source: #{@vault_source}"
284
326
  @stdout.puts "saved default vault: #{@settings.default_vault || '(none)'}"
285
327
  @stdout.puts "default vault config path: #{@settings.config_path}"
286
328
  @stdout.puts "obsidian cli executable: #{report[:executable]}"
287
329
  @stdout.puts "obsidian cli version: #{report[:version] || 'unknown'}"
288
- @stdout.puts "connection check: #{report[:status]}"
289
330
  @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
331
  end
293
332
 
294
- def handle_no_results(query, vault, vault_source)
333
+ def handle_no_results(query, diagnostics)
295
334
  ui.show_no_results(
296
335
  query,
297
- vault:,
298
- vault_source:,
336
+ diagnostics: diagnostics.to_h,
299
337
  default_vault_path: @settings.config_path,
300
338
  obsidian_executable: ENV.fetch('ARCHITEXT_OBSIDIAN', 'obsidian')
301
339
  )
@@ -311,48 +349,68 @@ module Architext
311
349
  raise error
312
350
  end
313
351
 
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]
352
+ def select_paths(paths, query:, diagnostics:)
353
+ return TUI::Selection.new(paths:, new_query: nil, source_config: false, reprompt_query: false) if @options[:all]
316
354
 
317
- unless interactive?
318
- raise Obsidian::CommandFailed,
319
- 'interactive selection requires a TTY; rerun with --all or provide input from a terminal'
320
- end
355
+ raise SourceError, 'interactive selection requires a TTY; rerun with --all or provide input from a terminal' unless interactive?
321
356
 
322
- ui.select(paths, query:, vault:, vault_source:)
357
+ ui.select(paths, query:, diagnostics: diagnostics.to_h)
323
358
  end
324
359
 
325
- def handle_prompt_vault_config
360
+ def handle_prompt_source_config
326
361
  loop do
327
- action = ui.prompt_vault_config(
362
+ context = {
363
+ source: @options[:source],
364
+ root: @options[:root],
365
+ root_source: @root_source,
328
366
  active_vault: @options[:vault],
329
367
  active_vault_source: @vault_source,
330
368
  default_vault: @settings.default_vault,
331
369
  default_vault_path: @settings.config_path
332
- )
370
+ }
371
+ action = ui.prompt_source_config(context)
333
372
  return if action.back
334
373
 
335
- if action.clear_default
336
- @settings.clear_default_vault
337
- ui.show_info('Default vault cleared.')
338
- next
339
- end
374
+ apply_source_config_action(action)
375
+ end
376
+ end
340
377
 
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
378
+ def build_source
379
+ return ObsidianSource.new(vault: @options[:vault], vault_source: @vault_source) if @options[:source] == SOURCE_OBSIDIAN
346
380
 
347
- next unless action.session_vault
381
+ NativeMarkdownSource.new(root: @options[:root])
382
+ end
348
383
 
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
384
+ def apply_source_config_action(action)
385
+ if action.clear_default
386
+ @settings.clear_default_vault
387
+ ui.show_info('Default vault cleared.')
388
+ elsif action.set_default_vault
389
+ @settings.default_vault = action.set_default_vault
390
+ ui.show_info("Default vault set to: #{action.set_default_vault}")
391
+ elsif action.session_root
392
+ apply_native_root(action.session_root)
393
+ elsif action.session_vault
394
+ apply_obsidian_vault(action.session_vault)
353
395
  end
354
396
  end
355
397
 
398
+ def apply_native_root(root)
399
+ @options[:source] = SOURCE_NATIVE
400
+ @options[:root] = File.expand_path(root.strip)
401
+ @root_source = ROOT_SOURCE_SESSION
402
+ @options[:vault] = nil
403
+ @vault_source = VAULT_SOURCE_OBSIDIAN_DEFAULT
404
+ end
405
+
406
+ def apply_obsidian_vault(vault_value)
407
+ @options[:source] = SOURCE_OBSIDIAN
408
+ vault = vault_value.strip
409
+ vault = nil if vault.empty?
410
+ @options[:vault] = vault
411
+ @vault_source = vault.nil? ? VAULT_SOURCE_OBSIDIAN_DEFAULT : VAULT_SOURCE_SESSION
412
+ end
413
+
356
414
  def print_dry_run(selected_paths, client)
357
415
  files = read_selected_files(client, selected_paths)
358
416
  bytes = Bundle.new(files).to_markdown.bytesize
@@ -374,7 +432,7 @@ module Architext
374
432
  @clipboard.copy(bundle)
375
433
  ui.show_copied(bundle.bytesize)
376
434
  rescue Clipboard::Error => e
377
- raise Obsidian::CommandFailed, "#{e.message}\nTip: rerun with --stdout to print the bundle."
435
+ raise SourceError, "#{e.message}\nTip: rerun with --stdout to print the bundle."
378
436
  end
379
437
 
380
438
  def query_uses_operators?(query)
@@ -397,25 +455,6 @@ module Architext
397
455
  nil
398
456
  end
399
457
 
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
458
  def vault_mismatch_warning(vault_summary, requested_vault)
420
459
  return nil if requested_vault.to_s.strip.empty?
421
460
  return nil if vault_summary.to_s.downcase.include?(requested_vault.to_s.downcase)
@@ -423,10 +462,6 @@ module Architext
423
462
  "Requested vault '#{requested_vault}' may not match resolved vault reported by Obsidian CLI."
424
463
  end
425
464
 
426
- def first_line(text)
427
- text.to_s.lines.first.to_s.strip
428
- end
429
-
430
465
  def no_selection
431
466
  ui.show_no_selection
432
467
  1
@@ -0,0 +1,272 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'find'
4
+ require 'pathname'
5
+
6
+ require_relative 'obsidian'
7
+
8
+ module Architext
9
+ SourceDiagnostics = Data.define(
10
+ :source,
11
+ :root,
12
+ :vault,
13
+ :vault_source,
14
+ :status,
15
+ :warning,
16
+ :markdown_count,
17
+ :executable,
18
+ :version,
19
+ :resolved_vault_summary
20
+ )
21
+
22
+ class SourceError < StandardError; end
23
+ class SourceNotFound < SourceError; end
24
+
25
+ class NativeMarkdownSource
26
+ MARKDOWN_EXTENSIONS = %w[.md .markdown].freeze
27
+ EXCLUDED_DIRECTORIES = %w[.git .bundle node_modules vendor].freeze
28
+
29
+ SearchToken = Data.define(:kind, :value)
30
+ SearchResult = Data.define(:path, :score)
31
+
32
+ attr_reader :root
33
+
34
+ def initialize(root: Dir.pwd)
35
+ @root = File.realpath(File.expand_path(root.to_s))
36
+ rescue Errno::ENOENT, Errno::ENOTDIR
37
+ raise SourceNotFound, "Markdown root not found: #{root}"
38
+ end
39
+
40
+ def search(query)
41
+ tokens = parse_query(query)
42
+ results = markdown_files.filter_map do |path|
43
+ relative = relative_path(path)
44
+ content = read_absolute(path)
45
+ next unless tokens.empty? || tokens.all? { |token| token_matches?(token, relative, content) }
46
+
47
+ SearchResult.new(path: relative, score: relevance_score(tokens, relative, content))
48
+ end
49
+
50
+ results.uniq(&:path)
51
+ .sort_by { |result| [-result.score, result.path.downcase] }
52
+ .map(&:path)
53
+ end
54
+
55
+ def read(path)
56
+ absolute = resolve_relative(path)
57
+ raise SourceNotFound, "Markdown file not found: #{path}" unless File.file?(absolute)
58
+ raise SourceError, "Not a markdown file: #{path}" unless markdown_file?(absolute)
59
+
60
+ read_absolute(absolute)
61
+ end
62
+
63
+ def diagnostics
64
+ SourceDiagnostics.new(
65
+ source: 'native',
66
+ root: @root,
67
+ vault: nil,
68
+ vault_source: nil,
69
+ status: 'ok',
70
+ warning: nil,
71
+ markdown_count: markdown_files.length,
72
+ executable: nil,
73
+ version: nil,
74
+ resolved_vault_summary: nil
75
+ )
76
+ rescue SourceError => e
77
+ SourceDiagnostics.new(
78
+ source: 'native',
79
+ root: @root,
80
+ vault: nil,
81
+ vault_source: nil,
82
+ status: 'error',
83
+ warning: e.message,
84
+ markdown_count: nil,
85
+ executable: nil,
86
+ version: nil,
87
+ resolved_vault_summary: nil
88
+ )
89
+ end
90
+
91
+ private
92
+
93
+ def markdown_files
94
+ files = []
95
+ Find.find(@root) do |path|
96
+ if File.directory?(path)
97
+ Find.prune if excluded_directory?(path)
98
+ next
99
+ end
100
+
101
+ files << path if markdown_file?(path)
102
+ end
103
+ files.sort_by { |path| relative_path(path).downcase }
104
+ end
105
+
106
+ def excluded_directory?(path)
107
+ return false if path == @root
108
+
109
+ basename = File.basename(path)
110
+ basename.start_with?('.') || EXCLUDED_DIRECTORIES.include?(basename)
111
+ end
112
+
113
+ def markdown_file?(path)
114
+ MARKDOWN_EXTENSIONS.include?(File.extname(path).downcase)
115
+ end
116
+
117
+ def parse_query(query)
118
+ query.to_s.scan(/"([^"]+)"|(\S+)/).filter_map do |quoted, bare|
119
+ raw = (quoted || bare).to_s.strip
120
+ next if raw.empty?
121
+
122
+ case raw
123
+ when /\Atag:(.+)\z/i
124
+ SearchToken.new(kind: :tag, value: Regexp.last_match(1).sub(/\A#/, '').downcase)
125
+ when /\A(?:path|file):(.+)\z/i
126
+ SearchToken.new(kind: :path, value: Regexp.last_match(1).downcase)
127
+ else
128
+ SearchToken.new(kind: :text, value: raw.downcase)
129
+ end
130
+ end
131
+ end
132
+
133
+ def token_matches?(token, relative, content)
134
+ path = relative.downcase
135
+ text = content.downcase
136
+
137
+ case token.kind
138
+ when :path
139
+ path.include?(token.value)
140
+ when :tag
141
+ tag_matches?(token.value, content)
142
+ else
143
+ path.include?(token.value) || text.include?(token.value)
144
+ end
145
+ end
146
+
147
+ def tag_matches?(tag, content)
148
+ return false if tag.empty?
149
+
150
+ escaped = Regexp.escape(tag)
151
+ pattern = if tag.include?('/')
152
+ %r{(?<![\w/-])##{escaped}(?![\w/-])}i
153
+ else
154
+ %r{(?<![\w/-])##{escaped}(?:/[\w-]+)*(?![\w/-])}i
155
+ end
156
+ content.match?(pattern)
157
+ end
158
+
159
+ def relevance_score(tokens, relative, content)
160
+ return 0 if tokens.empty?
161
+
162
+ path = relative.downcase
163
+ text = content.downcase
164
+ tokens.sum do |token|
165
+ case token.kind
166
+ when :path
167
+ path.include?(token.value) ? 30 : 0
168
+ when :tag
169
+ tag_matches?(token.value, content) ? 40 : 0
170
+ else
171
+ (path.include?(token.value) ? 20 : 0) + text.scan(Regexp.escape(token.value)).length
172
+ end
173
+ end
174
+ end
175
+
176
+ def resolve_relative(path)
177
+ requested = Pathname.new(File.expand_path(path.to_s, @root)).cleanpath
178
+ root_path = Pathname.new(@root)
179
+ relative = requested.relative_path_from(root_path).to_s
180
+ raise SourceError, "Path escapes markdown root: #{path}" if relative.start_with?('..')
181
+
182
+ requested.to_s
183
+ rescue ArgumentError
184
+ raise SourceError, "Path escapes markdown root: #{path}"
185
+ end
186
+
187
+ def relative_path(path)
188
+ Pathname.new(path).relative_path_from(Pathname.new(@root)).to_s
189
+ end
190
+
191
+ def read_absolute(path)
192
+ text = File.binread(path).to_s
193
+ text.force_encoding(Encoding::UTF_8)
194
+ text.valid_encoding? ? text : text.scrub
195
+ rescue Errno::EACCES
196
+ raise SourceError, "Cannot read markdown file: #{relative_path(path)}"
197
+ end
198
+ end
199
+
200
+ class ObsidianSource
201
+ attr_reader :vault, :vault_source
202
+
203
+ def initialize(vault: nil, vault_source: nil, executable: ENV.fetch('ARCHITEXT_OBSIDIAN', 'obsidian'))
204
+ @vault = vault
205
+ @vault_source = vault_source
206
+ @executable = executable
207
+ @client = Obsidian.new(vault:, executable:)
208
+ end
209
+
210
+ def search(query)
211
+ @client.search(query)
212
+ end
213
+
214
+ def read(path)
215
+ @client.read(path)
216
+ end
217
+
218
+ def diagnostics
219
+ version = @client.version
220
+ vault_info = @client.vault_info
221
+ SourceDiagnostics.new(
222
+ source: 'obsidian',
223
+ root: nil,
224
+ vault: @vault,
225
+ vault_source: @vault_source,
226
+ status: 'ok',
227
+ warning: nil,
228
+ markdown_count: nil,
229
+ executable: @executable,
230
+ version:,
231
+ resolved_vault_summary: summarize_vault_info(vault_info)
232
+ )
233
+ rescue Obsidian::CommandFailed => e
234
+ SourceDiagnostics.new(
235
+ source: 'obsidian',
236
+ root: nil,
237
+ vault: @vault,
238
+ vault_source: @vault_source,
239
+ status: 'error',
240
+ warning: first_line(e.message),
241
+ markdown_count: nil,
242
+ executable: @executable,
243
+ version: nil,
244
+ resolved_vault_summary: nil
245
+ )
246
+ end
247
+
248
+ private
249
+
250
+ def summarize_vault_info(text)
251
+ lines = text.to_s.lines.map(&:strip).reject(&:empty?)
252
+ kv = lines.each_with_object({}) do |line, memo|
253
+ next unless line.match?(/\A[a-zA-Z0-9_]+\s+/)
254
+
255
+ key, value = line.split(/\s+/, 2)
256
+ memo[key.downcase] = value
257
+ end
258
+
259
+ name = kv['name']
260
+ path = kv['path']
261
+ return "#{name} | #{path}" if name && path
262
+ return name if name
263
+ return path if path
264
+
265
+ lines.join(' | ')[0, 180]
266
+ end
267
+
268
+ def first_line(text)
269
+ text.to_s.lines.first.to_s.strip
270
+ end
271
+ end
272
+ end
data/lib/architext/tui.rb CHANGED
@@ -8,7 +8,7 @@ require_relative 'version'
8
8
  module Architext
9
9
  # rubocop:disable Metrics/ClassLength
10
10
  class TUI
11
- HELP = 'Up/k Down/j move space select a all / filter n new search v vault enter confirm q back'
11
+ HELP = 'Up/k Down/j move space select a all / filter n new search v source enter confirm q back'
12
12
  KEY_BINDINGS = {
13
13
  ' ' => :space,
14
14
  'k' => :up,
@@ -27,9 +27,9 @@ module Architext
27
27
  '/_/ |_/_/ \\___/_/ /_/_/\\__/\\___/_/|_|\\__/ '
28
28
  ].freeze
29
29
 
30
- Selection = Data.define(:paths, :new_query, :new_vault, :reprompt_query)
30
+ Selection = Data.define(:paths, :new_query, :source_config, :reprompt_query)
31
31
  QueryPrompt = Data.define(:query, :open_vault_config, :quit)
32
- VaultConfigAction = Data.define(:session_vault, :set_default_vault, :clear_default, :back)
32
+ SourceConfigAction = Data.define(:session_root, :session_vault, :set_default_vault, :clear_default, :back)
33
33
 
34
34
  def initialize(stdin:, stdout:, stderr:, app_name:)
35
35
  @stdin = stdin
@@ -42,14 +42,8 @@ module Architext
42
42
 
43
43
  def prompt_query(default:, context:)
44
44
  draw_intro
45
- draw_startup_vault_status(
46
- context[:vault],
47
- context[:vault_source],
48
- context[:default_vault],
49
- context[:default_vault_path],
50
- context[:connection_report]
51
- )
52
- @stdout.print render("[bold][cyan]Search query[/] [dim](default: #{default})[/] [dim]| type 'v' for vault config, 'q' to quit:[/] ")
45
+ draw_startup_source_status(context)
46
+ @stdout.print render("[bold][cyan]Search query[/] [dim](default: #{default})[/] [dim]| type 'v' for source config, 'q' to quit:[/] ")
53
47
  input = @stdin.gets&.strip
54
48
  return QueryPrompt.new(query: nil, open_vault_config: false, quit: true) if input.nil?
55
49
 
@@ -61,49 +55,54 @@ module Architext
61
55
  end
62
56
 
63
57
  # rubocop:disable Metrics/AbcSize
64
- def prompt_vault_config(active_vault:, active_vault_source:, default_vault:, default_vault_path:)
58
+ def prompt_source_config(context)
65
59
  draw_intro
66
- @stdout.puts render('[bold][cyan]Vault Configuration[/]')
67
- @stdout.puts render("[dim]active:[/] #{format_vault_label(active_vault, active_vault_source)}")
68
- @stdout.puts render("[dim]saved default:[/] #{format_saved_default(default_vault)}")
69
- @stdout.puts render("[dim]default config path:[/] #{default_vault_path}")
70
- @stdout.puts render('[dim]vault path resolution is handled by Obsidian CLI via vault=<name_or_id>.[/]')
60
+ @stdout.puts render('[bold][cyan]Source Configuration[/]')
61
+ @stdout.puts render("[dim]active source:[/] [cyan]#{context[:source]}[/]")
62
+ @stdout.puts render("[dim]native root:[/] #{format_root_label(context[:root], context[:root_source])}")
63
+ @stdout.puts render("[dim]obsidian vault:[/] #{format_vault_label(context[:active_vault], context[:active_vault_source])}")
64
+ @stdout.puts render("[dim]saved Obsidian default:[/] #{format_saved_default(context[:default_vault])}")
65
+ @stdout.puts render("[dim]Obsidian default config path:[/] #{context[:default_vault_path]}")
71
66
  @stdout.puts
72
67
  @stdout.puts render('[dim]Commands:[/]')
73
- @stdout.puts render(' [cyan]use <vault>[/] [dim]set active vault for this run[/]')
74
- @stdout.puts render(' [cyan]save <vault>[/] [dim]save persistent default vault[/]')
75
- @stdout.puts render(' [cyan]clear[/] [dim]clear persistent default vault[/]')
76
- @stdout.puts render(' [cyan]none[/] [dim]clear active vault (use Obsidian CLI default)[/]')
68
+ @stdout.puts render(' [cyan]root <path>[/] [dim]use native markdown search in a folder[/]')
69
+ @stdout.puts render(' [cyan]vault <vault>[/] [dim]use Obsidian CLI with a vault name or id[/]')
70
+ @stdout.puts render(' [cyan]save <vault>[/] [dim]save persistent Obsidian default vault[/]')
71
+ @stdout.puts render(' [cyan]clear[/] [dim]clear persistent Obsidian default vault[/]')
72
+ @stdout.puts render(' [cyan]none[/] [dim]use Obsidian CLI default vault[/]')
77
73
  @stdout.puts render(' [cyan]back[/] [dim]return to search prompt[/]')
78
74
  @stdout.puts
79
- @stdout.print render('[bold][cyan]vault-config[/]> ')
75
+ @stdout.print render('[bold][cyan]source-config[/]> ')
80
76
  input = @stdin.gets&.strip
81
- return VaultConfigAction.new(session_vault: nil, set_default_vault: nil, clear_default: false, back: true) if input.nil?
77
+ return source_config_action(back: true) if input.nil?
82
78
 
83
79
  command = input.strip
84
- return VaultConfigAction.new(session_vault: nil, set_default_vault: nil, clear_default: false, back: true) if command.empty?
85
- return VaultConfigAction.new(session_vault: nil, set_default_vault: nil, clear_default: false, back: true) if command.casecmp('back').zero?
86
- return VaultConfigAction.new(session_vault: '', set_default_vault: nil, clear_default: false, back: false) if command.casecmp('none').zero?
87
- return VaultConfigAction.new(session_vault: nil, set_default_vault: nil, clear_default: true, back: false) if command.casecmp('clear').zero?
80
+ return source_config_action(back: true) if command.empty?
81
+ return source_config_action(back: true) if command.casecmp('back').zero?
82
+ return source_config_action(session_vault: '') if command.casecmp('none').zero?
83
+ return source_config_action(clear_default: true) if command.casecmp('clear').zero?
88
84
 
89
85
  if (match = command.match(/\Asave\s+(.+)\z/i))
90
- return VaultConfigAction.new(session_vault: nil, set_default_vault: match[1].strip, clear_default: false, back: false)
86
+ return source_config_action(set_default_vault: match[1].strip)
91
87
  end
92
88
 
93
- if (match = command.match(/\Ause\s+(.+)\z/i))
94
- return VaultConfigAction.new(session_vault: match[1].strip, set_default_vault: nil, clear_default: false, back: false)
89
+ if (match = command.match(/\A(?:root|native)\s+(.+)\z/i))
90
+ return source_config_action(session_root: match[1].strip)
95
91
  end
96
92
 
97
- VaultConfigAction.new(session_vault: command, set_default_vault: nil, clear_default: false, back: false)
93
+ if (match = command.match(/\A(?:vault|obsidian|use)\s+(.+)\z/i))
94
+ return source_config_action(session_vault: match[1].strip)
95
+ end
96
+
97
+ source_config_action(session_root: command)
98
98
  end
99
99
  # rubocop:enable Metrics/AbcSize
100
100
 
101
- # rubocop:disable Metrics/BlockLength, Metrics/MethodLength
102
- def select(paths, query:, vault:, vault_source:)
101
+ # rubocop:disable Metrics/BlockLength
102
+ def select(paths, query:, diagnostics:)
103
103
  state = {
104
104
  query: query,
105
- vault: vault,
106
- vault_source: vault_source,
105
+ diagnostics: diagnostics,
107
106
  filter: '',
108
107
  cursor: 0,
109
108
  offset: 0,
@@ -130,24 +129,24 @@ module Architext
130
129
  when :new_query
131
130
  return Selection.new(
132
131
  paths: [],
133
- new_query: prompt_inline('New Obsidian search', state[:query]),
134
- new_vault: nil,
132
+ new_query: prompt_inline('New markdown search', state[:query]),
133
+ source_config: false,
135
134
  reprompt_query: false
136
135
  )
137
136
  when :new_vault
138
137
  return Selection.new(
139
138
  paths: [],
140
139
  new_query: nil,
141
- new_vault: prompt_inline('Vault name or id (blank clears)', state[:vault].to_s),
140
+ source_config: true,
142
141
  reprompt_query: false
143
142
  )
144
143
  when :enter
145
144
  selected = selected_paths(paths, state)
146
- return Selection.new(paths: selected, new_query: nil, new_vault: nil, reprompt_query: false)
145
+ return Selection.new(paths: selected, new_query: nil, source_config: false, reprompt_query: false)
147
146
  when :quit
148
- return Selection.new(paths: [], new_query: nil, new_vault: nil, reprompt_query: true)
147
+ return Selection.new(paths: [], new_query: nil, source_config: false, reprompt_query: true)
149
148
  when :ctrl_c
150
- return Selection.new(paths: [], new_query: nil, new_vault: nil, reprompt_query: false)
149
+ return Selection.new(paths: [], new_query: nil, source_config: false, reprompt_query: false)
151
150
  end
152
151
 
153
152
  clamp_cursor!(state, visible.length)
@@ -155,14 +154,15 @@ module Architext
155
154
  end
156
155
  end
157
156
  end
158
- # rubocop:enable Metrics/BlockLength, Metrics/MethodLength
157
+ # rubocop:enable Metrics/BlockLength
159
158
 
160
- def show_no_results(query, vault:, vault_source:, default_vault_path:, obsidian_executable:)
161
- vault_label = format_vault_label(vault, vault_source)
162
- @stderr.puts render("[red]No Obsidian notes matched[/] [amber]#{query.inspect}[/] #{vault_label}")
163
- @stderr.puts render("[dim]default vault config:[/] #{default_vault_path}")
164
- @stderr.puts render("[dim]obsidian cli:[/] #{obsidian_executable}")
165
- @stderr.puts render('[amber]Tip:[/] at search prompt type [bold]v[/] for vault config, or pass [bold]--vault[/].')
159
+ def show_no_results(query, diagnostics:, default_vault_path:, obsidian_executable:)
160
+ @stderr.puts render("[red]No markdown notes matched[/] [amber]#{query.inspect}[/] #{format_source_label(diagnostics)}")
161
+ if diagnostics[:source] == 'obsidian'
162
+ @stderr.puts render("[dim]default vault config:[/] #{default_vault_path}")
163
+ @stderr.puts render("[dim]obsidian cli:[/] #{obsidian_executable}")
164
+ end
165
+ @stderr.puts render('[amber]Tip:[/] at search prompt type [bold]v[/] for source config, or pass [bold]--root[/].')
166
166
  end
167
167
 
168
168
  def show_no_selection
@@ -192,6 +192,16 @@ module Architext
192
192
 
193
193
  private
194
194
 
195
+ def source_config_action(
196
+ session_root: nil,
197
+ session_vault: nil,
198
+ set_default_vault: nil,
199
+ clear_default: false,
200
+ back: false
201
+ )
202
+ SourceConfigAction.new(session_root:, session_vault:, set_default_vault:, clear_default:, back:)
203
+ end
204
+
195
205
  def draw_intro
196
206
  return unless @stdout.tty?
197
207
  return if @intro_rendered
@@ -212,7 +222,7 @@ module Architext
212
222
  styled = logo_style ? Terminal.paint(line, logo_style, enabled: @color) : line
213
223
  @stdout.puts center(styled, width)
214
224
  end
215
- @stdout.puts center(render('[dim]Architect Obsidian context and stitch for agent work[/]'), width)
225
+ @stdout.puts center(render('[dim]Architect markdown context and stitch for agent work[/]'), width)
216
226
  version = "v#{Architext::VERSION}"
217
227
  styled_version = version_style ? Terminal.paint(version, version_style, enabled: @color) : version
218
228
  @stdout.puts center(styled_version, width)
@@ -280,7 +290,7 @@ module Architext
280
290
  def header_lines(width, state, total, visible_count)
281
291
  filter_label = state[:filter].empty? ? 'none' : state[:filter]
282
292
  [
283
- Terminal.truncate(render("[bold][cyan]ARCHiTEXT[/] [dim]#{state[:vault] || 'obsidian default'}[/]"), width),
293
+ Terminal.truncate(render("[bold][cyan]ARCHiTEXT[/] [dim]#{format_source_label(state[:diagnostics])}[/]"), width),
284
294
  Terminal.truncate(render("[dim]query:[/] [amber]#{state[:query]}[/] [dim]filter:[/] [cyan]#{filter_label}[/]"), width),
285
295
  Terminal.truncate(render("[dim]results:[/] #{visible_count}/#{total} [dim]selected:[/] #{state[:selected].length}"), width),
286
296
  Terminal.paint('-' * width, :faint, enabled: @color)
@@ -482,20 +492,36 @@ module Architext
482
492
  Terminal.render(markup, enabled: @color)
483
493
  end
484
494
 
485
- def draw_startup_vault_status(vault, vault_source, default_vault, default_vault_path, connection_report)
486
- @stdout.puts render("[dim]active vault:[/] #{format_vault_label(vault, vault_source)}")
487
- @stdout.puts render("[dim]saved default:[/] #{format_saved_default(default_vault)}")
488
- @stdout.puts render("[dim]default config path:[/] #{default_vault_path}")
489
- @stdout.puts render("[dim]obsidian cli:[/] #{connection_report[:executable]}")
490
- @stdout.puts render("[dim]obsidian version:[/] #{connection_report[:version] || 'unknown'}")
495
+ def draw_startup_source_status(context)
496
+ connection_report = context[:connection_report]
497
+ @stdout.puts render("[dim]active source:[/] #{format_source_label(connection_report)}")
498
+ @stdout.puts render("[dim]native root:[/] #{format_root_label(context[:root], context[:root_source])}")
499
+ if connection_report[:source] == 'obsidian'
500
+ @stdout.puts render("[dim]obsidian vault:[/] #{format_vault_label(context[:vault], context[:vault_source])}")
501
+ @stdout.puts render("[dim]saved Obsidian default:[/] #{format_saved_default(context[:default_vault])}")
502
+ @stdout.puts render("[dim]default vault config path:[/] #{context[:default_vault_path]}")
503
+ @stdout.puts render("[dim]obsidian cli:[/] #{connection_report[:executable]}")
504
+ @stdout.puts render("[dim]obsidian version:[/] #{connection_report[:version] || 'unknown'}")
505
+ else
506
+ @stdout.puts render("[dim]markdown files:[/] #{connection_report[:markdown_count] || 'unknown'}")
507
+ end
491
508
  status_style = connection_report[:status] == 'ok' ? '[green]ok[/]' : '[red]error[/]'
492
509
  @stdout.puts render("[dim]connection check:[/] #{status_style}")
493
510
  @stdout.puts render("[dim]resolved vault:[/] #{connection_report[:resolved_vault_summary]}") if connection_report[:resolved_vault_summary]
494
511
  @stdout.puts render("[amber]diagnostic:[/] #{connection_report[:warning]}") if connection_report[:warning]
495
- @stdout.puts render('[dim]vault target semantics: CWD vault if inside one, otherwise active Obsidian vault unless overridden.[/]')
496
512
  @stdout.puts
497
513
  end
498
514
 
515
+ def format_source_label(diagnostics)
516
+ return format_root_label(diagnostics[:root], 'root') if diagnostics[:source] == 'native'
517
+
518
+ format_vault_label(diagnostics[:vault], diagnostics[:vault_source] || 'obsidian')
519
+ end
520
+
521
+ def format_root_label(root, source)
522
+ "[cyan]#{root}[/] [dim](#{source})[/]"
523
+ end
524
+
499
525
  def format_vault_label(vault, source)
500
526
  return "[amber]none selected[/] [dim](#{source})[/]" if vault.to_s.strip.empty?
501
527
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Architext
4
- VERSION = '0.2.0'
4
+ VERSION = '1.0.0'
5
5
  end
data/lib/architext.rb CHANGED
@@ -9,5 +9,6 @@ require_relative 'architext/terminal'
9
9
  require_relative 'architext/search_results'
10
10
  require_relative 'architext/selection_parser'
11
11
  require_relative 'architext/settings'
12
+ require_relative 'architext/sources'
12
13
  require_relative 'architext/tui'
13
14
  require_relative 'architext/version'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: architext
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Can
@@ -79,8 +79,8 @@ dependencies:
79
79
  - - "~>"
80
80
  - !ruby/object:Gem::Version
81
81
  version: '5.0'
82
- description: A terminal interface to interactively select and bundle Obsidian notes
83
- for AI agent context.
82
+ description: A terminal interface to interactively search, select, and bundle markdown
83
+ notes for AI agent context.
84
84
  executables:
85
85
  - architext
86
86
  extensions: []
@@ -100,6 +100,7 @@ files:
100
100
  - lib/architext/search_results.rb
101
101
  - lib/architext/selection_parser.rb
102
102
  - lib/architext/settings.rb
103
+ - lib/architext/sources.rb
103
104
  - lib/architext/terminal.rb
104
105
  - lib/architext/tui.rb
105
106
  - lib/architext/version.rb
@@ -129,5 +130,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
129
130
  requirements: []
130
131
  rubygems_version: 3.6.9
131
132
  specification_version: 4
132
- summary: A visual Obsidian context stitching TUI for agent workflows.
133
+ summary: A visual markdown context stitching TUI for agent workflows.
133
134
  test_files: []