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 +4 -4
- data/CHANGELOG.md +13 -1
- data/README.md +92 -35
- data/lib/architext/cli.rb +130 -95
- data/lib/architext/sources.rb +272 -0
- data/lib/architext/tui.rb +83 -57
- data/lib/architext/version.rb +1 -1
- data/lib/architext.rb +1 -0
- metadata +5 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 98ba60f856ccd4030ef1c0bbedc1cdef3ee2e1cb4fbfd2bbbf6208c0360123a3
|
|
4
|
+
data.tar.gz: 2fd7551d931e0a96a700cd78df0b9ed88cc301fb209ad09a2f32b3cfea504976
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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/
|
|
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
|
|
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/
|
|
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
|
-
|
|
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
|
|
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
|
|
46
|
-
architext --
|
|
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
|
-
- **
|
|
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
|
|
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
|
-
###
|
|
125
|
+
### Optional Obsidian CLI Path
|
|
93
126
|
|
|
94
|
-
If the CLI is not in your `PATH`,
|
|
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
|
-
#
|
|
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
|
|
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
|
-
###
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
-
|
|
142
|
-
-
|
|
143
|
-
-
|
|
144
|
-
-
|
|
145
|
-
-
|
|
146
|
-
-
|
|
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
|
|
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
|
|
165
|
-
| `v` | Set/change active
|
|
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
|
|
180
|
-
-
|
|
181
|
-
-
|
|
182
|
-
-
|
|
183
|
-
-
|
|
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,
|
|
68
|
+
print_dry_run(selected_paths, source)
|
|
59
69
|
return 0
|
|
60
70
|
end
|
|
61
71
|
|
|
62
|
-
files = read_selected_files(
|
|
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', "
|
|
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
|
|
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
|
-
|
|
145
|
-
attempt = search_with_recovery(
|
|
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,
|
|
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:,
|
|
162
|
-
if selection.
|
|
163
|
-
|
|
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
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
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,
|
|
333
|
+
def handle_no_results(query, diagnostics)
|
|
295
334
|
ui.show_no_results(
|
|
296
335
|
query,
|
|
297
|
-
|
|
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:,
|
|
315
|
-
return TUI::Selection.new(paths:, new_query: nil,
|
|
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:,
|
|
357
|
+
ui.select(paths, query:, diagnostics: diagnostics.to_h)
|
|
323
358
|
end
|
|
324
359
|
|
|
325
|
-
def
|
|
360
|
+
def handle_prompt_source_config
|
|
326
361
|
loop do
|
|
327
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
next
|
|
339
|
-
end
|
|
374
|
+
apply_source_config_action(action)
|
|
375
|
+
end
|
|
376
|
+
end
|
|
340
377
|
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
381
|
+
NativeMarkdownSource.new(root: @options[:root])
|
|
382
|
+
end
|
|
348
383
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
@
|
|
352
|
-
|
|
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
|
|
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
|
|
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, :
|
|
30
|
+
Selection = Data.define(:paths, :new_query, :source_config, :reprompt_query)
|
|
31
31
|
QueryPrompt = Data.define(:query, :open_vault_config, :quit)
|
|
32
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
|
58
|
+
def prompt_source_config(context)
|
|
65
59
|
draw_intro
|
|
66
|
-
@stdout.puts render('[bold][cyan]
|
|
67
|
-
@stdout.puts render("[dim]active:[/] #{
|
|
68
|
-
@stdout.puts render("[dim]
|
|
69
|
-
@stdout.puts render("[dim]
|
|
70
|
-
@stdout.puts render(
|
|
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]
|
|
74
|
-
@stdout.puts render(' [cyan]
|
|
75
|
-
@stdout.puts render(' [cyan]
|
|
76
|
-
@stdout.puts render(' [cyan]
|
|
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]
|
|
75
|
+
@stdout.print render('[bold][cyan]source-config[/]> ')
|
|
80
76
|
input = @stdin.gets&.strip
|
|
81
|
-
return
|
|
77
|
+
return source_config_action(back: true) if input.nil?
|
|
82
78
|
|
|
83
79
|
command = input.strip
|
|
84
|
-
return
|
|
85
|
-
return
|
|
86
|
-
return
|
|
87
|
-
return
|
|
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
|
|
86
|
+
return source_config_action(set_default_vault: match[1].strip)
|
|
91
87
|
end
|
|
92
88
|
|
|
93
|
-
if (match = command.match(/\
|
|
94
|
-
return
|
|
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
|
-
|
|
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
|
|
102
|
-
def select(paths, query:,
|
|
101
|
+
# rubocop:disable Metrics/BlockLength
|
|
102
|
+
def select(paths, query:, diagnostics:)
|
|
103
103
|
state = {
|
|
104
104
|
query: query,
|
|
105
|
-
|
|
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
|
|
134
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
157
|
+
# rubocop:enable Metrics/BlockLength
|
|
159
158
|
|
|
160
|
-
def show_no_results(query,
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
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[:
|
|
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
|
|
486
|
-
|
|
487
|
-
@stdout.puts render("[dim]
|
|
488
|
-
@stdout.puts render("[dim]
|
|
489
|
-
|
|
490
|
-
|
|
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
|
|
data/lib/architext/version.rb
CHANGED
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.
|
|
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
|
|
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
|
|
133
|
+
summary: A visual markdown context stitching TUI for agent workflows.
|
|
133
134
|
test_files: []
|