hiiro 0.1.378 → 0.1.380

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: bc8c7a68d7b0fc2d7d6182b1d307978ba4ed185d4b6162800821ef12c8fa5d4e
4
- data.tar.gz: 3708447b7e35a09e59deb4cc570a770619054c299852e3ead032f6240180cfcf
3
+ metadata.gz: f16b6af02e89f46207b5e15d23e5e112413036a2da29ff1257cc5fec2025a655
4
+ data.tar.gz: 1b16f6ecf825bddf0ee1672710b09c31bac6db4489158ea0de6b714446cce1cf
5
5
  SHA512:
6
- metadata.gz: 0c4a5a117425f0b879607a1040d7a757b696b0b4773d8ff1cc4747a403123029217e3ec4744031ff45b14a3f5565727c8a3a2d40097a017cf8b6bcbd68b4c93f
7
- data.tar.gz: 219ff3e0aa32313353c2aad202c745788d1a6327816a90b54e9b9adee90a975709c64a36ce40c7f5425510af1e979240eb213f496ddd81da151e39e4249ee550
6
+ metadata.gz: 54990684ed981d3387fdfa212fdcc58ad55b6ac9f5422f79644b5bdf8c48f4d29aee5cdcbf33cd887903eee970b1ebc326b3b930e4808623001ca45c59b9283d
7
+ data.tar.gz: aed85297b9d4c67a7634c99d37d1c6c27f387e9c1dd784f652f9c9199c48c883e2b8a12c123574acb6a57b7f6752299f4c4285b109750ae6dd45e7f6d0b173ec
data/CHANGELOG.md CHANGED
@@ -10,6 +10,9 @@
10
10
  - `t path`, `t cd`, `t start`, and `t switch` accept optional `APP` argument to select a configured relative directory beneath the task root
11
11
  - APP resolves by exact name or unique case-sensitive prefix; fails when ambiguous or missing
12
12
 
13
+ ### Added
14
+ - `t switch` / `t workspace` and `t cd` accept an optional APP positional (or `--app`) resolving through the apps registry by exact name or unique prefix; the workspace or pane cd targets the app directory under the task worktree, e.g. `t switch aldi cpm` opens `~/work/aldi/main/retailer-tools/content-page-migrator`
15
+
13
16
  ## [0.1.376] - 2026-09-14
14
17
 
15
18
  ### Changed
data/README.md CHANGED
@@ -92,8 +92,11 @@ prefix or a missing current task opens the fuzzy finder; non-interactive runs fa
92
92
 
93
93
  The current task is the calling Herdr workspace, then the current directory
94
94
  inside a task home, code directory, worktree, or registered directory, then the
95
- task saved with `t use TASK`. `t switch TASK` focuses or creates the task
96
- workspace and also saves it. `t current` only prints.
95
+ task saved with `t use TASK`. `t switch TASK [APP]` focuses or creates the task
96
+ workspace and also saves it; APP (exact name or unique prefix from the apps
97
+ registry) opens the workspace in that app's directory under the task worktree.
98
+ `t cd TASK [APP]` sends the same `cd` to the calling Herdr pane. `t current`
99
+ only prints.
97
100
 
98
101
  ```sh
99
102
  t new investigation
@@ -105,7 +108,7 @@ t show investigation
105
108
  t todo rm 42 # an ID printed by show or todo list
106
109
  t use investigation
107
110
  t switch investigation
108
- t switch investigation frontend # app name or unique prefix
111
+ t switch investigation frontend # open in an app directory (unique prefix)
109
112
  t switch --show
110
113
  t next . "Write the handoff"
111
114
  t start investigation frontend # tree new + app start directory
data/bin/h-docs CHANGED
@@ -1,385 +1,245 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
3
  require 'hiiro'
4
- begin
5
- require 'hiiro/tui'
6
- rescue LoadError
7
- require File.expand_path('~/proj/hiiro/lib/hiiro/tui')
8
- end
9
4
  require 'json'
10
- require 'time'
5
+ require 'net/http'
6
+ require 'uri'
11
7
 
12
- DOCS_DIR = File.join(Dir.home, 'claude', 'docs')
13
- MDOC = File.join(Dir.home, 'bin', 'mdoc')
14
- INDEX_JSON = File.join(Dir.home, 'claude', 'index.json')
15
- INDEX_HTML = File.join(Dir.home, 'claude', 'index.html')
8
+ SERVER = ENV.fetch('HDOCS_SERVER', 'http://127.0.0.1:4387').sub(%r{/$}, '')
9
+ DOCS_DIR = File.join(Dir.home, 'claude', 'docs')
10
+ PROJECT_ROOT = ENV.fetch('PORTFOLIO_HOME', File.join(Dir.home, 'proj', 'portfolio'))
11
+ SERVICE_LABEL = "com.#{ENV.fetch('USER')}.portfolio"
12
+ SERVICE_DOMAIN = "gui/#{Process.uid}"
13
+ SERVICE_TARGET = "#{SERVICE_DOMAIN}/#{SERVICE_LABEL}"
14
+ SERVICE_PLIST = File.join(Dir.home, 'Library', 'LaunchAgents', "#{SERVICE_LABEL}.plist")
16
15
 
17
16
  ls_opts = Hiiro::Options.setup {
18
- flag(:html, short: :h, default: false, desc: 'show only .html files')
19
- flag(:md, short: :m, default: false, desc: 'show only .md files')
20
- flag(:full, short: :f, default: false, desc: 'show full absolute paths')
17
+ flag(:html, short: :h, default: false, desc: 'show HTML-backed documents')
18
+ flag(:md, short: :m, default: false, desc: 'show Markdown-backed documents')
19
+ flag(:full, short: :f, default: false, desc: 'show API URLs and source paths')
21
20
  option(:limit, short: :l, default: nil, desc: 'limit number of results')
21
+ flag(:files, default: false, desc: 'with rm, also delete the tracked source file')
22
22
  }
23
23
 
24
24
  open_opts = Hiiro::Options.setup {
25
- flag(:html, short: :h, default: false, desc: 'consider only .html files')
26
- flag(:md, short: :m, default: false, desc: 'consider only .md files')
27
- flag(:all, short: :a, default: false, desc: 'open ALL matches (skip fuzzyfind)')
25
+ flag(:html, short: :h, default: false, desc: 'consider HTML-backed documents')
26
+ flag(:md, short: :m, default: false, desc: 'consider Markdown-backed documents')
27
+ flag(:all, short: :a, default: false, desc: 'open all matches')
28
28
  }
29
29
 
30
- def file_patterns(opts)
31
- patterns = []
32
- patterns << '*.html' if opts.html
33
- patterns << '*.md' if opts.md
34
- patterns.empty? ? ['*.html', '*.md'] : patterns
35
- end
36
-
37
- def build_regex(filters)
38
- filters = ['.*'] if filters.empty?
39
- Regexp.new(filters.join(?|), Regexp::IGNORECASE)
40
- end
41
-
42
- def find_docs(opts)
43
- re = build_regex(opts.args)
44
- file_patterns(opts).flat_map { |pat| Dir[File.join(DOCS_DIR, '**', pat)] }
45
- .select { |path| File.basename(path).match?(re) }
46
- end
47
-
48
- def display(path, opts)
49
- opts.respond_to?(:full) && opts.full ? path : File.basename(path)
50
- end
51
-
52
- def pick_one(paths)
53
- return paths.first if paths.length == 1
54
- basenames = paths.map { |p| File.basename(p) }
55
- choice = fuzzyfind(basenames)
56
- return nil if choice.nil? || choice.strip.empty?
57
- paths.find { |p| File.basename(p) == choice.strip }
58
- end
59
-
60
- class DocsIndex
61
- Entry = Struct.new(:key, :entry, :md_path, :html_path, keyword_init: true) do
62
- def in_index?
63
- !entry.nil?
64
- end
65
-
66
- def has_md?
67
- md_path && File.file?(md_path)
68
- end
69
-
70
- def has_html?
71
- html_path && File.file?(html_path)
72
- end
73
-
74
- def basename
75
- "#{key}.html"
76
- end
77
-
78
- def title
79
- entry&.fetch('title', nil).to_s.empty? ? key : entry['title']
80
- end
81
-
82
- def to_s
83
- basename
84
- end
30
+ class PortfolioApi
31
+ def initialize(base_url)
32
+ @base = URI(base_url)
85
33
  end
86
34
 
87
- def initialize(docs_dir:, index_json:, mdoc:)
88
- @docs_dir = docs_dir
89
- @index_json = index_json
90
- @mdoc = mdoc
91
- @pending_delete_paths = []
92
- @changed = false
93
- load_entries
94
- end
95
-
96
- attr_reader :entries
97
-
98
- def changed?
99
- @changed
100
- end
35
+ attr_reader :base
101
36
 
102
- def rows
103
- indexed = entries.each_with_index.to_h { |entry, idx| [key_for_file(entry['file']), [entry, idx]] }
104
- keys = indexed.keys
105
- keys += Dir[File.join(@docs_dir, '*.md')].map { |path| File.basename(path, '.md') }
106
- keys += Dir[File.join(@docs_dir, '*.html')].map { |path| File.basename(path, '.html') }
107
-
108
- keys.uniq.sort_by { |key| indexed[key] ? [0, indexed[key][1]] : [1, key.downcase] }.map do |key|
109
- entry = indexed.dig(key, 0)
110
- Entry.new(
111
- key: key,
112
- entry: entry,
113
- md_path: File.join(@docs_dir, "#{key}.md"),
114
- html_path: html_path_for(key, entry)
115
- )
116
- end
37
+ def items(filters = [], limit: nil)
38
+ query = {}
39
+ query['q'] = filters.join(' ') unless filters.empty?
40
+ query['limit'] = limit.to_i if limit
41
+ get('/api/items', query).fetch('items')
117
42
  end
118
43
 
119
- def toggle(row)
120
- if row.in_index?
121
- remove(row)
122
- "Marked #{row.basename} for index removal and file deletion on save"
123
- elsif row.has_html?
124
- add(row)
125
- "Added #{row.basename} to index"
126
- else
127
- "Cannot add #{row.basename}: no html file exists"
128
- end
44
+ def health
45
+ get('/health')
129
46
  end
130
47
 
131
- def regenerate(row)
132
- return 'Regenerate requires an indexed row with markdown and missing html' unless row.in_index? && row.has_md? && !row.has_html?
133
-
134
- return "Failed to regenerate #{row.basename}" unless system(@mdoc, row.md_path)
135
-
136
- load_entries
137
- @changed = true
138
- "Regenerated #{row.basename}"
48
+ def delete(id, files: false)
49
+ target = uri("/api/items/#{id}")
50
+ target.query = URI.encode_www_form(delete_files: 1) if files
51
+ request(Net::HTTP::Delete.new(target))
52
+ true
139
53
  end
140
54
 
141
- def save
142
- return true unless changed?
55
+ def absolute_url(path)
56
+ return path if path.to_s.match?(%r{\Ahttps?://})
143
57
 
144
- @pending_delete_paths.uniq.each do |path|
145
- File.delete(path) if path && File.file?(path)
146
- end
147
- File.write(@index_json, JSON.pretty_generate(entries) + "\n")
148
- system(@mdoc, '--rebuild-index')
58
+ URI.join("#{base}/", path.to_s.sub(%r{\A/}, '')).to_s
149
59
  end
150
60
 
151
61
  private
152
62
 
153
- def load_entries
154
- @entries = JSON.parse(File.read(@index_json))
155
- rescue Errno::ENOENT, JSON::ParserError
156
- @entries = []
63
+ def get(path, query = {})
64
+ target = uri(path)
65
+ target.query = URI.encode_www_form(query) unless query.empty?
66
+ JSON.parse(request(Net::HTTP::Get.new(target)))
157
67
  end
158
68
 
159
- def key_for_file(file)
160
- File.basename(file.to_s, '.html')
69
+ def uri(path)
70
+ URI.join("#{base}/", path.sub(%r{\A/}, ''))
161
71
  end
162
72
 
163
- def html_path_for(key, entry)
164
- file = entry&.fetch('file', nil)
165
- return File.join(File.dirname(@docs_dir), file) if file && !file.empty?
73
+ def request(message)
74
+ response = Net::HTTP.start(message.uri.hostname, message.uri.port, open_timeout: 2, read_timeout: 15) { |http| http.request(message) }
75
+ raise "Portfolio API returned #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess)
166
76
 
167
- File.join(@docs_dir, "#{key}.html")
77
+ response.body
78
+ rescue Errno::ECONNREFUSED, SocketError => error
79
+ raise "Portfolio is unavailable at #{base}: #{error.message}"
168
80
  end
81
+ end
169
82
 
170
- def html_rel_for(row)
171
- "docs/#{File.basename(row.html_path)}"
172
- end
83
+ API = PortfolioApi.new(SERVER)
173
84
 
174
- def add(row)
175
- rel = html_rel_for(row)
176
- @pending_delete_paths.reject! { |path| path == row.md_path || path == row.html_path }
177
- entries.reject! { |entry| entry['file'] == rel }
178
- entries.unshift(
179
- 'title' => title_for(row),
180
- 'file' => rel,
181
- 'date' => File.mtime(row.html_path).strftime('%Y-%m-%d'),
182
- 'desc' => '',
183
- 'ts' => Time.now.iso8601
184
- )
185
- row.entry = entries.first
186
- @changed = true
187
- end
85
+ def source_kind?(item, opts)
86
+ return true unless opts.html || opts.md
188
87
 
189
- def remove(row)
190
- rel = html_rel_for(row)
191
- entries.reject! { |entry| entry['file'] == rel || key_for_file(entry['file']) == row.key }
192
- @pending_delete_paths << row.md_path
193
- @pending_delete_paths << row.html_path
194
- row.entry = nil
195
- @changed = true
196
- end
88
+ path = item['source_path'].to_s
89
+ (opts.html && path.end_with?('.html')) || (opts.md && path.end_with?('.md'))
90
+ end
197
91
 
198
- def title_for(row)
199
- return row.title if row.in_index?
200
- return markdown_title(row.md_path) if row.has_md?
92
+ def matching_items(opts, limit: nil)
93
+ API.items(opts.args, limit: limit).select { |item| source_kind?(item, opts) }
94
+ end
201
95
 
202
- row.key.tr('-', ' ')
203
- end
96
+ def item_label(item)
97
+ "#{item['title']} [#{item['type']} ##{item['id']}]"
98
+ end
204
99
 
205
- def markdown_title(path)
206
- File.foreach(path) do |line|
207
- next unless line.start_with?('# ')
100
+ def pick_one(items)
101
+ return items.first if items.length == 1
208
102
 
209
- return line.sub(/^#\s+/, '').strip
210
- end
103
+ labels = items.map { |item| item_label(item) }
104
+ choice = fuzzyfind(labels)
105
+ return nil if choice.nil? || choice.strip.empty?
211
106
 
212
- File.basename(path, '.md').tr('-', ' ')
213
- end
107
+ items.find { |item| item_label(item) == choice.strip }
214
108
  end
215
109
 
216
- class DocsIndexTui < Hiiro::Tui::ListScreen
217
- def initialize(index:)
218
- @index = index
219
- @message = 'space toggle index/files r regenerate missing html enter save/rebuild q cancel'
220
- super(items: index.rows, empty_message: 'No docs found.')
221
- end
110
+ def open_item(item)
111
+ system('open', API.absolute_url(item['href']))
112
+ end
222
113
 
223
- def handle_key(key)
224
- case key
225
- when ' '
226
- @message = @index.toggle(items[cursor])
227
- refresh
228
- when 'r'
229
- @message = @index.regenerate(items[cursor])
230
- refresh
231
- when :enter
232
- return @index.save
233
- else
234
- super
235
- end
236
- end
114
+ def service_loaded?
115
+ system('launchctl', 'print', SERVICE_TARGET, out: File::NULL, err: File::NULL)
116
+ end
237
117
 
238
- def header_lines
239
- [
240
- 'h-docs tui',
241
- 'I=index entry M=markdown file H=html file',
242
- @message,
243
- ]
118
+ def start_service
119
+ if service_loaded?
120
+ system('launchctl', 'kickstart', SERVICE_TARGET)
121
+ else
122
+ system('launchctl', 'bootstrap', SERVICE_DOMAIN, SERVICE_PLIST)
244
123
  end
124
+ end
245
125
 
246
- def footer_lines
247
- ["Showing #{top + 1}-#{top + visible_items.length} of #{items.length} docs"]
248
- end
126
+ def stop_service
127
+ return puts('Portfolio is not loaded.') unless service_loaded?
249
128
 
250
- def format_row(row, current, line_cols)
251
- prefix = current ? '> ' : ' '
252
- indicators = [
253
- row.in_index? ? 'I' : '-',
254
- row.has_md? ? 'M' : '-',
255
- row.has_html? ? 'H' : '-',
256
- ].join
257
- fixed = "#{prefix}#{indicators} "
258
- text_cols = [line_cols - fixed.length, 1].max
259
- text = (fixed + visible_text(row.basename, text_cols, horizontal_offset)).ljust(line_cols)
260
- style =
261
- if current
262
- "\e[7m"
263
- elsif row.in_index? && row.has_md? && !row.has_html?
264
- "\e[33m"
265
- else
266
- "\e[0m"
267
- end
129
+ system('launchctl', 'bootout', SERVICE_TARGET)
130
+ end
268
131
 
269
- "#{style}#{text}\e[0m\e[K"
132
+ def restart_service
133
+ if service_loaded?
134
+ system('launchctl', 'kickstart', '-k', SERVICE_TARGET)
135
+ else
136
+ start_service
270
137
  end
138
+ end
271
139
 
272
- def max_horizontal_offset(line_cols = nil, visible = nil)
273
- line_cols ||= [terminal_cols - 1, 1].max
274
- visible ||= visible_items
275
- return 0 if visible.empty?
140
+ Hiiro.run(*ARGV, plugins: [Pins]) {
141
+ add_subcmd(:setup, :install) { |*_args|
142
+ installer = File.join(PROJECT_ROOT, 'bin', 'install')
143
+ abort "Installer not found: #{installer}\nSet PORTFOLIO_HOME to the project directory." unless File.executable?(installer)
144
+ exit($?.exitstatus || 1) unless system(installer)
145
+ }
276
146
 
277
- fixed_width = 6
278
- remaining_cols = [line_cols - fixed_width, 1].max
279
- min_visible_chars = [5, remaining_cols].min
280
- longest = visible.map { |row| row.basename.length }.max || 0
147
+ add_subcmd(:start) { |*_args|
148
+ abort "LaunchAgent not installed. Run: h-docs setup" unless File.file?(SERVICE_PLIST)
149
+ exit($?.exitstatus || 1) unless start_service
150
+ puts 'Portfolio started.'
151
+ }
281
152
 
282
- [longest - min_visible_chars, 0].max
283
- end
153
+ add_subcmd(:stop) { |*_args|
154
+ stop_service
155
+ }
284
156
 
285
- private
157
+ add_subcmd(:restart) { |*_args|
158
+ abort "LaunchAgent not installed. Run: h-docs setup" unless File.file?(SERVICE_PLIST)
159
+ exit($?.exitstatus || 1) unless restart_service
160
+ puts 'Portfolio restarted.'
161
+ }
286
162
 
287
- def refresh
288
- @items = @index.rows
289
- if items.empty?
290
- @cursor = 0
291
- @top = 0
292
- return :continue
163
+ add_subcmd(:status) { |*_args|
164
+ if service_loaded?
165
+ system('launchctl', 'print', SERVICE_TARGET)
166
+ else
167
+ warn 'Portfolio is not loaded. Run: h-docs start'
168
+ exit 1
293
169
  end
170
+ }
294
171
 
295
- @cursor = [[cursor, 0].max, items.length - 1].min
296
- @top = [[top, 0].max, @cursor].min
297
- :continue
298
- end
299
- end
300
-
301
- Hiiro.run(*ARGV, plugins: [Pins]) {
302
172
  add_subcmd(:ls) { |*args|
303
- opts = ls_opts.parse(args)
304
- paths = find_docs(opts).sort
305
- paths = paths.last(opts.limit.to_i) if opts.limit
306
- paths.each { |p| puts display(p, opts) }
173
+ opts = ls_opts.parse(args)
174
+ matching_items(opts, limit: opts.limit).each do |item|
175
+ if opts.full
176
+ puts [API.absolute_url(item['href']), item['source_path']].compact.join("\t")
177
+ else
178
+ puts item_label(item)
179
+ end
180
+ end
307
181
  }
308
182
 
309
183
  add_subcmd(:recent) { |*args|
310
- opts = ls_opts.parse(args)
311
- numeric = opts.args.find { |a| a =~ /^\d+$/ }
312
- n = (opts.limit || numeric || 10).to_i
313
- filters = opts.args.reject { |a| a =~ /^\d+$/ }
314
- re = build_regex(filters)
315
-
316
- paths = file_patterns(opts).flat_map { |pat| Dir[File.join(DOCS_DIR, '**', pat)] }
317
- .select { |p| File.basename(p).match?(re) }
318
- .sort_by { |p| -File.mtime(p).to_i }
319
- .first(n)
320
-
321
- paths.each { |p| puts display(p, opts) }
184
+ opts = ls_opts.parse(args)
185
+ numeric = opts.args.find { |arg| arg.match?(/^\d+$/) }
186
+ count = (opts.limit || numeric || 10).to_i
187
+ opts.args.delete(numeric) if numeric
188
+ matching_items(opts, limit: count).each { |item| puts item_label(item) }
322
189
  }
323
190
 
324
191
  add_subcmd(:open) { |*args|
325
- opts = open_opts.parse(args)
326
- paths = find_docs(opts)
327
- next puts('No matching docs.') if paths.empty?
192
+ opts = open_opts.parse(args)
193
+ if opts.args.empty? && !opts.html && !opts.md && !opts.all
194
+ next system('open', SERVER)
195
+ end
196
+
197
+ items = matching_items(opts)
198
+ next puts('No matching items.') if items.empty?
328
199
 
329
- targets = opts.all ? paths : [pick_one(paths)].compact
330
- targets.each { |t| system('open', t) }
200
+ (opts.all ? items : [pick_one(items)].compact).each { |item| open_item(item) }
331
201
  }
332
202
 
333
203
  add_subcmd(:vim) { |*args|
334
- opts = ls_opts.parse(args)
335
- paths = find_docs(opts).sort
336
- next puts('No matching docs.') if paths.empty?
204
+ opts = ls_opts.parse(args)
205
+ paths = matching_items(opts).filter_map { |item| item['source_path'] if File.file?(item['source_path'].to_s) }
206
+ next puts('No matching local sources.') if paths.empty?
207
+
337
208
  edit_files(*paths)
338
209
  }
339
210
 
340
211
  add_subcmd(:cat) { |*args|
341
- opts = ls_opts.parse(args)
342
- paths = find_docs(opts).sort
343
- next puts('No matching docs.') if paths.empty?
344
-
345
- paths.each do |p|
346
- puts "===== #{File.basename(p)} ====="
347
- puts File.read(p)
348
- puts
349
- end
212
+ opts = ls_opts.parse(args)
213
+ paths = matching_items(opts).filter_map { |item| item['source_path'] if File.file?(item['source_path'].to_s) }
214
+ next puts('No matching local sources.') if paths.empty?
215
+
216
+ paths.each { |path| puts "===== #{File.basename(path)} =====", File.read(path), '' }
350
217
  }
351
218
 
352
219
  add_subcmd(:rm) { |*args|
353
- opts = ls_opts.parse(args)
354
- paths = find_docs(opts)
355
- next puts('No matching docs.') if paths.empty?
356
-
357
- target = pick_one(paths)
358
- next unless target
359
- File.delete(target)
360
- puts "Removed: #{target}"
220
+ opts = ls_opts.parse(args)
221
+ items = matching_items(opts)
222
+ next puts('No matching items.') if items.empty?
223
+
224
+ item = pick_one(items)
225
+ next unless item
226
+
227
+ API.delete(item['id'], files: opts.files)
228
+ suffix = opts.files ? ' and deleted tracked source file' : ''
229
+ puts "Removed from Portfolio#{suffix}: #{item_label(item)}"
361
230
  }
362
231
 
363
232
  add_subcmd(:count) { |*_args|
364
- html = Dir[File.join(DOCS_DIR, '**', '*.html')].count
365
- md = Dir[File.join(DOCS_DIR, '**', '*.md')].count
366
- puts "html: #{html}"
367
- puts "md: #{md}"
368
- puts "total: #{html + md}"
233
+ puts "items: #{API.health.fetch('items')}"
369
234
  }
370
235
 
371
- add_subcmd(:index) { |*_args|
372
- system('open', INDEX_HTML)
236
+ add_subcmd(:index, :tui) { |*_args|
237
+ system('open', SERVER)
373
238
  }
374
239
 
375
240
  add_subcmd(:reindex, :rebuild) { |*_args|
376
- exit($?.exitstatus || 1) unless system(MDOC, '--rebuild-index')
377
- }
378
-
379
- add_subcmd(:tui) { |*_args|
380
- index = DocsIndex.new(docs_dir: DOCS_DIR, index_json: INDEX_JSON, mdoc: MDOC)
381
- saved = DocsIndexTui.new(index: index).run
382
- puts(saved ? 'Saved docs index.' : 'Canceled docs index changes.')
241
+ status = API.health
242
+ puts "Portfolio search index is live (#{status.fetch('items')} items)."
383
243
  }
384
244
 
385
245
  add_subcmd(:path) { |*_args|
data/bin/hh ADDED
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'hiiro'
4
+
5
+ Hiiro.run do
6
+ add_cmd(:ls) do
7
+ cmd = 'herdr', 'session', 'list', *opts.original_args
8
+ temp = Tempfile.new('asdf')
9
+ system(*cmd)
10
+ end
11
+
12
+ add_cmd(:switch, :start) do
13
+ cmd = 'herdr', 'session', 'list'
14
+ temp = Tempfile.new('asdf')
15
+ system(*cmd, out: temp.path)
16
+
17
+ lines = File.readlines(temp.path, chomp: true)
18
+
19
+ sessions = lines.filter_map.with_index{|x, i| next if i == 0; [x, x.split(/\s+/, 2).first] }.to_h
20
+
21
+ ses =
22
+ if opts.args.empty?
23
+ fuzzyfind_from_map(sessions)
24
+ else
25
+ opts.args.first
26
+ end
27
+
28
+ use = sessions.values.find{|ss| ss.start_with?(ses) }
29
+
30
+ system('herdr', '--session', use)
31
+ end
32
+
33
+ add_cmd(:kill, :stop, :rm, :remove) do
34
+ tmp = Tempfile.new 'asdf'
35
+ result = system 'herdr', 'session' ,'stop', *opts.args, err: tmp.path
36
+
37
+ if result
38
+ result = system 'herdr', 'session' ,'delete', *opts.args
39
+ else
40
+ reason = File.read tmp.path
41
+ if reason[/running/]
42
+ result = system 'herdr', 'session' ,'delete', *opts.args
43
+ end
44
+ end
45
+ end
46
+
47
+ add_default do |*args|
48
+ system 'herdr', *args
49
+ end
50
+ end
data/docs/t.md CHANGED
@@ -95,7 +95,7 @@ t doc open [TASK] [NAME]
95
95
  | `t path [TASK] [APP]` | Start directory, optionally beneath the configured relative directory for APP |
96
96
  | `t branch [TASK]` | Worktree branch or `(detached)` |
97
97
  | `t sh [TASK] [CMD...]` | Changes to the start directory and execs a shell or CMD |
98
- | `t cd [TASK] [APP]` | Sends `cd` to the calling Herdr pane |
98
+ | `t cd [TASK] [APP]` | Sends `cd` to the calling Herdr pane; APP narrows to a registry app directory under the task worktree |
99
99
 
100
100
  Worktree creation is `Hiiro::TaskManager#create_tree`, shared with the legacy `h task start` code path.
101
101
 
@@ -104,7 +104,7 @@ APP resolves against configured Hiiro apps by exact name or unique case-sensitiv
104
104
  ## Herdr workspaces, tabs, and panes
105
105
 
106
106
  ```text
107
- t switch [TASK] [APP] [--directory PATH] [--show] alias: workspace
107
+ t switch [TASK] [APP] [--directory PATH] [--show] alias: workspace
108
108
  t tab ls [TASK]
109
109
  t tab new [TASK] [LABEL] [--directory PATH] [--command COMMAND]
110
110
  t tab open [TASK] [ID|LABEL]
@@ -117,6 +117,10 @@ t pane split [TASK] ID|LABEL [--direction right|down] [--directory PATH] [--comm
117
117
 
118
118
  These require a running Herdr server. The workspace label is the task session or name with `.` replaced by `_`. For a task, optional APP selects its configured relative directory when creating the workspace; when the workspace already exists, `switch` focuses it and sends `cd` to its focused pane. APP and `--directory` cannot be combined. `switch` also accepts the name of a live Herdr workspace that belongs to no task, exactly or by unique prefix, and focuses it; APP is invalid for loose workspaces and panes. A task with the same name wins. An exact pane ID such as `w6:p2` focuses that pane, and the picker lists every live pane with its directory and foreground command. When the name is ambiguous, or no task or context is given, a terminal gets a fuzzy finder over tasks and loose workspaces, with duplicate workspace names numbered in the label only. Commands that jump somewhere never assume the current task: `t switch`, `t tab open`, and `t pane open` with no task open the picker instead (or fail when stdin is not a terminal); use `.` explicitly for the current task.
119
119
 
120
+ ### App directories
121
+
122
+ `t switch TASK APP` and `t cd TASK APP` accept APP as an exact name or unique prefix from the apps registry (`Hiiro::AppRecord`); `--app APP` is the flag form. The workspace opens (or the pane cds) into the app's directory under the task worktree, e.g. with `aldi` registered as `retailer-tools/content-page-migrator`, `t switch aldi cpm` opens `~/work/aldi/main/retailer-tools/content-page-migrator`. An ambiguous prefix is an error; APP only applies to task workspaces, not panes or loose workspaces.
123
+
120
124
  ## Native AI sessions
121
125
 
122
126
  `t omp [TASK] [ARGS...]`, `t codex` / `cdx`, and `t claude` / `cld` create a new focused tab in the task workspace, creating the workspace if needed, and run the native tool with ARGS forwarded verbatim. A leading argument that is a prefix of `resume` turns into the tool's native resume flag; bare `resume` with exactly one running pane of that tool focuses it instead.
@@ -63,6 +63,18 @@ class Hiiro
63
63
  lookup_task(reference) || raise(Error, "Task not found: #{reference}; run t new #{reference}")
64
64
  end
65
65
 
66
+ # [name, relative_path] from the apps registry: exact name wins, else a
67
+ # unique prefix; ambiguity and no-match are errors.
68
+ def lookup_app!(reference)
69
+ apps = Hiiro::AppRecord.all_as_hash
70
+ raise Error, 'No apps are configured' if apps.empty?
71
+ return [reference, apps[reference]] if apps[reference]
72
+ found = Hiiro::Matcher.by_prefix(apps.keys.sort, reference).matches.map(&:item)
73
+ raise Error, "No app matches #{reference}" if found.empty?
74
+ raise Error, "Ambiguous app #{reference}: #{found.join(', ')}" unless found.one?
75
+ [found.first, apps[found.first]]
76
+ end
77
+
66
78
  # Current task from Herdr, directory, or the saved pin. With no context at all,
67
79
  # a TTY gets the fuzzy finder over every task; ambiguous or stale context still fails.
68
80
  def current_task
@@ -469,6 +481,26 @@ class Hiiro
469
481
  herdr
470
482
  end
471
483
  end
484
+ # App reference (positional or --app) resolves through the apps registry
485
+ # under the task worktree; explicit param wins over the option.
486
+ def start_directory(task, app: nil)
487
+ app = opts&.fetch(:app) if app.nil?
488
+ path = opts&.fetch(:directory) || (app && app_directory(task, app)) || code_directory(task) || ensure_home(task)
489
+ existing_path(path, directory: true)
490
+ end
491
+
492
+ # Registry app directory under the task worktree.
493
+ def app_directory(task, reference)
494
+ _name, relative = lookup_app!(reference)
495
+ root = code_directory(task)
496
+ raise Error, "#{task.name} has no worktree; run t tree new #{task.name} first" unless root
497
+ File.join(root, relative)
498
+ end
499
+
500
+ def open_workspace(task, save:, app: nil)
501
+ open_task_workspace(task, start_directory(task, app: app))
502
+ save_current(task) if save
503
+ end
472
504
 
473
505
  def workspace_for(task, required: true)
474
506
  label = workspace_label(task)
@@ -480,29 +512,6 @@ class Hiiro
480
512
  matches.first
481
513
  end
482
514
 
483
- def app_directory(path, app_name)
484
- return path unless app_name
485
- result = Hiiro::Matcher.new(Hiiro::AppRecord.all, :name).by_prefix(app_name)
486
- app = result.resolved&.item
487
- if !app && result.ambiguous?
488
- raise Error, "Ambiguous app #{app_name}: #{result.matches.map { |match| match.item.name }.join(', ')}"
489
- end
490
- raise Error, "App not found: #{app_name}" unless app
491
- File.join(path, app.path)
492
- end
493
-
494
- def start_directory(task, app_name = nil)
495
- path = opts&.fetch(:directory)
496
- raise Error, 'Use an app or --directory, not both' if path && app_name
497
- path ||= code_directory(task) || ensure_home(task)
498
- existing_path(app_directory(path, app_name), directory: true)
499
- end
500
-
501
- def open_workspace(task, save:, app_name: nil)
502
- open_task_workspace(task, start_directory(task, app_name), chdir: !app_name.nil?)
503
- save_current(task) if save
504
- end
505
-
506
515
  # Focus the task workspace or create it at directory. With optional: true,
507
516
  # a stopped Herdr prints a hint instead of failing (used after worktree changes).
508
517
  def open_task_workspace(task, directory, optional: false, chdir: false)
@@ -556,51 +565,47 @@ class Hiiro
556
565
  puts pane_line(pane)
557
566
  end
558
567
 
559
- # [target, explicit, app_name]. A task wins over a workspace with the same name.
568
+ # [target, explicit, rest]. A task wins over a workspace with the same name.
569
+ # The first positional names the target; any remaining positional is the app.
560
570
  def switch_target(args)
561
571
  args = args.dup
562
- return [lookup_task!(opts.task), true, single(args, optional: true)] if opts.task
563
- return [pick_switch_target(Hiiro::TaskRecord.all_as_list, loose_workspaces), true, single(args, optional: true)] if opts.find
564
- first = args.first
572
+ first = args.shift
573
+ rest = args
574
+ if opts.task || opts.find
575
+ # Flag forces the target, so the first positional is the app.
576
+ app = [first].compact
577
+ no_args!(rest)
578
+ return [lookup_task!(opts.task), true, app] if opts.task
579
+ return [pick_switch_target(Hiiro::TaskRecord.all_as_list, loose_workspaces), true, app] if opts.find
580
+ end
581
+ no_args!(rest.drop(1))
565
582
  if first && first != '.' && !first.start_with?('-')
566
- args.shift
567
- app_name = single(args, optional: true)
568
583
  tasks = Hiiro::TaskRecord.all_as_list
569
584
  workspaces = loose_workspaces
570
585
  exact = tasks.find { |task| task.name == first }
571
- return [exact, true, app_name] if exact
586
+ return [exact, true, rest] if exact
572
587
  pane = live_panes.values.flatten.find { |candidate| candidate.id == first }
573
- raise Error, 'An app applies only to task workspaces' if pane && app_name
574
- return [pane, false, nil] if pane
588
+ return [pane, false, rest] if pane
575
589
  exact_ws = workspaces.select { |workspace| workspace.name == first }
576
- if exact_ws.one?
577
- raise Error, 'An app applies only to task workspaces' if app_name
578
- return [exact_ws.first, false, nil]
579
- end
590
+ return [exact_ws.first, false, rest] if exact_ws.one?
580
591
  task_matches = tasks.select { |task| task.name.start_with?(first) }
581
592
  ws_matches = exact_ws.any? ? exact_ws : workspaces.select { |workspace| workspace.name.start_with?(first) }
582
- return [task_matches.first, true, app_name] if task_matches.one? && ws_matches.empty?
583
- if ws_matches.one? && task_matches.empty?
584
- raise Error, 'An app applies only to task workspaces' if app_name
585
- return [ws_matches.first, false, nil]
586
- end
593
+ return [task_matches.first, true, rest] if task_matches.one? && ws_matches.empty?
594
+ return [ws_matches.first, false, rest] if ws_matches.one? && task_matches.empty?
587
595
  raise Error, "No task or workspace matches #{first}" if task_matches.empty? && ws_matches.empty?
588
596
  names = (task_matches.map(&:name) + ws_matches.map { |w| "#{w.name} (workspace)" }).join(', ')
589
597
  raise Error, "Ambiguous #{first}: #{names}" unless $stdin.tty?
590
598
  target = pick_switch_target(task_matches, ws_matches, panes: [])
591
- raise Error, 'An app applies only to task workspaces' if app_name && !target.is_a?(Hiiro::TaskRecord)
592
- return [target, target.is_a?(Hiiro::TaskRecord), app_name]
599
+ return [target, target.is_a?(Hiiro::TaskRecord), rest]
593
600
  end
601
+ no_args!(rest)
594
602
  if first == '.'
595
- args.shift
596
- app_name = single(args, optional: true)
597
- return [Hiiro::CurrentTask.new(herdr: -> { herdr_client }, pin: true).resolve!, false, app_name]
603
+ return [Hiiro::CurrentTask.new(herdr: -> { herdr_client }, pin: true).resolve!, false, rest]
598
604
  end
599
- no_args!(args)
600
605
  # Jumping somewhere never assumes the current task: pick a destination.
601
606
  raise Error, 'Name a task, workspace, or pane to switch to, or use . for the current task' unless $stdin.tty?
602
607
  target = pick_switch_target(Hiiro::TaskRecord.all_as_list, loose_workspaces)
603
- [target, target.is_a?(Hiiro::TaskRecord), nil]
608
+ [target, target.is_a?(Hiiro::TaskRecord), rest]
604
609
  end
605
610
 
606
611
  def require_picker!(what)
@@ -707,10 +712,10 @@ class Hiiro
707
712
  command.empty? ? exec(ENV['SHELL'] || 'zsh') : exec(*command)
708
713
  end
709
714
 
710
- def cd_to(task, app_name = nil)
715
+ def cd_to(task, app: nil)
711
716
  pane = ENV['HERDR_PANE_ID']
712
717
  raise Error, 'Not running inside a Herdr pane' unless pane
713
- check_result(client.run_in_pane(pane, "cd #{start_directory(task, app_name).shellescape}"))
718
+ check_result(client.run_in_pane(pane, "cd #{start_directory(task, app: app).shellescape}"))
714
719
  end
715
720
 
716
721
  def show_workspace(task)
@@ -850,45 +855,36 @@ class Hiiro
850
855
  end
851
856
  end
852
857
 
853
- add_cmd(:path, args: ['task?', 'app?'], opts: TASK_OPTIONS) do
854
- task, rest = take_task(opts.args)
855
- puts start_directory(task, single(rest, optional: true))
856
- end
857
- add_cmd(:branch, args: ['task?'], opts: TASK_OPTIONS) { puts current_branch(take_task_only(opts.args)) }
858
- add_cmd(:cd, args: ['task?', 'app?'], opts: TASK_OPTIONS) do
859
- task, rest = take_task(opts.args)
860
- cd_to(task, single(rest, optional: true))
861
- end
862
- add_cmd(:sh, args: ['task?', 'command...'], passthrough: true) do
863
- task, rest = take_task(args, options: nil)
864
- run_shell(task, rest)
865
- end
866
-
867
858
  add_option :directory, desc: 'Existing start directory'
868
- add_option :app, desc: 'App directory to open in the workspace'
869
- add_option :sparse, short: :s, desc: 'Sparse checkout group (repeatable)', multi: true
870
- add_cmd(:start, args: %i[name app?], opts: [*TASK_OPTIONS, :app, :sparse]) do
871
- if opts.task
872
- new_tree([lookup_task!(opts.task).name, *opts.args], opts.app, opts.sparse)
873
- elsif opts.find
874
- new_tree([pick_task(Hiiro::TaskRecord.all_as_list).name, *opts.args], opts.app, opts.sparse)
875
- else
876
- new_tree(opts.args, opts.app, opts.sparse)
877
- end
878
- end
879
- add_cmd(:switch, :workspace, args: ['task-or-workspace?', 'app?'], opts: [*TASK_OPTIONS, :directory, :show]) do
880
- target, explicit, app_name = switch_target(opts.args)
859
+ add_option :app, desc: 'Registry app directory under the task worktree'
860
+ add_cmd(:switch, :workspace, args: ['task-or-workspace?', 'app?'], opts: [*TASK_OPTIONS, :directory, :app, :show]) do
861
+ target, explicit, rest = switch_target(opts.args)
862
+ app = opts.app || rest.first
881
863
  if target.is_a?(Hiiro::Herdr::Pane)
882
864
  raise Error, '--show applies to task workspaces' if opts.show
865
+ raise Error, 'An APP only applies to task workspaces' if app
883
866
  focus_pane_target(target)
884
867
  elsif target.is_a?(Hiiro::Herdr::Workspace)
885
868
  raise Error, '--show applies to task workspaces' if opts.show
869
+ raise Error, 'An APP only applies to task workspaces' if app
886
870
  check_result(client.focus_workspace(target.id))
887
871
  puts target
888
872
  else
889
- opts.show ? show_workspace(target) : open_workspace(target, save: explicit, app_name: app_name)
873
+ opts.show ? show_workspace(target) : open_workspace(target, save: explicit, app: app)
890
874
  end
891
875
  end
876
+ add_cmd(:path, args: ['task?'], opts: TASK_OPTIONS) { puts start_directory(take_task_only(opts.args)) }
877
+ add_cmd(:branch, args: ['task?'], opts: TASK_OPTIONS) { puts current_branch(take_task_only(opts.args)) }
878
+ add_cmd(:cd, args: ['task?', 'app?'], opts: TASK_OPTIONS) do
879
+ task, rest = take_task(opts.args)
880
+ no_args!(rest.drop(1))
881
+ cd_to(task, app: rest.first)
882
+ end
883
+
884
+ add_cmd(:sh, args: ['task?', 'command...'], passthrough: true) do
885
+ task, rest = take_task(args, options: nil)
886
+ run_shell(task, rest)
887
+ end
892
888
 
893
889
  [%w[omp], %w[codex cdx], %w[claude cld]].each do |names|
894
890
  add_cmd(*names, args: ['task?', 'cli-args...'], passthrough: true) do
data/lib/hiiro/version.rb CHANGED
@@ -1,4 +1,4 @@
1
1
  class Hiiro
2
- VERSION = "0.1.378"
2
+ VERSION = "0.1.380"
3
3
  SUPPORTED_RUBY_VERSION = ">= 3.2.0"
4
4
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hiiro
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.378
4
+ version: 0.1.380
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joshua Toyota
@@ -219,6 +219,7 @@ files:
219
219
  - bin/h-wtree
220
220
  - bin/h-xxserve
221
221
  - bin/h-zosubtask
222
+ - bin/hh
222
223
  - bin/t
223
224
  - bin/tt
224
225
  - demo.sh