hiiro 0.1.385 → 0.1.387.pre.1

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: 1877449a8336392c36f54f38f1ea86792b736f6d8af25bea8bb98f1f62081328
4
- data.tar.gz: 495465e2395a43cc24464922aa60e24283e97bf715f7492f7be182e8936629c6
3
+ metadata.gz: acd7043754dc01539f98a7f622e8201bf06799e41f540f3901eedca29ad28235
4
+ data.tar.gz: 7f37ee293a6e11a7ef5f8f85682891e9e4773d91fbd79a2f6e561a1e6ec8764b
5
5
  SHA512:
6
- metadata.gz: 0d1c6e96f27300ab32b675f10b2d00c207792a0534859bbbfaa02f72d855b3af33572422842474cdfddf3a18cd5146eb93060349b03671232b4b4485c262079d
7
- data.tar.gz: 0db00cb34ddc01b743a1a3f039e80308619134ce722e9f316cc483f36c1db1579d749218be66ab30e31b664996f6eeb9e89a6ffacff6c1763ce8d47dc8193e7e
6
+ metadata.gz: 5b2177c62610d52720d1672ad276c79e8b928aa021daf9e813c41752c1c6b22b83564a6893736e0cd3369e03270cddf18b361135841b8ec94fd31fcf8a0a7f43
7
+ data.tar.gz: b250d4434fd950326aff95fd35b52a2c8cba8024ea89ebbee2525b28d816615fea788136f83798170e7a2ac1d5f3080ccbf440158ca3b803ff3b646dd166178b
data/AGENTS.md CHANGED
@@ -187,6 +187,8 @@ Hiiro::Matcher.by_prefix(items, "pre", key: :name)
187
187
  Hiiro::Matcher.by_substring(items, "abc", key: :name)
188
188
  ```
189
189
 
190
+ `Result` and `PathResult` share their cardinality/resolution answers through `Hiiro::Matcher::ResultQueries` (`count`, `one?`, `ambiguous?`, `match?`, `exact_match`, `exact?`, `match`, `resolved`, `first`); both expose the original query as `query`.
191
+
190
192
  Note: `Hiiro::PrefixMatcher` is aliased to `Hiiro::Matcher` for backward compatibility.
191
193
 
192
194
  ### Hiiro::Git (lib/hiiro/git.rb)
@@ -198,7 +200,8 @@ git = hiiro.git
198
200
  git.root # Repository root path
199
201
  git.branch # Current branch name
200
202
  git.branches # List all branches
201
- git.worktrees # List all worktrees
203
+ git.worktrees # List all worktrees (Git::Worktree: path, head, branch, bare?, detached?, name, to_line)
204
+ Hiiro::Git::Worktrees.fetch # Porcelain-backed collection; bins should use this instead of splitting `git worktree list`
202
205
  git.add_worktree(path, branch:) # Create worktree
203
206
  git.move_worktree(from, to) # Rename worktree
204
207
  git.current_pr # Get current PR info
@@ -281,6 +284,130 @@ Or via `h db q`:
281
284
  h db q "SELECT bin_name, argv_json, invoked_at FROM invocations ORDER BY invoked_at DESC LIMIT 10"
282
285
  ```
283
286
 
287
+ ### Hiiro::InputFile / Hiiro::EditedDocument (lib/hiiro/input_file.rb)
288
+
289
+ Tempfile + editor lifecycle for YAML/markdown input. After `edit`, the result is retained as an `EditedDocument`:
290
+
291
+ ```ruby
292
+ input = yaml_input_file(content: yaml, prefix: 'todo-edit-') # or InputFile.yaml_file(hiiro:, ...) / .md_file
293
+ doc = input.edit.document # EditedDocument: original, result (alias contents), parsed (alias data), error
294
+ doc.changed? / doc.empty? / doc.valid?
295
+ input.parsed_file # still available; nil on YAML parse error
296
+ input.cleanup # unlink the tempfile when done
297
+ ```
298
+
299
+ Use this instead of a hand-rolled Tempfile/system/YAML.load/unlink cycle (TodoManager#edit_items now does).
300
+
301
+ ### Hiiro::CheckSummary / Hiiro::CheckContext (lib/hiiro/check_summary.rb)
302
+
303
+ One interpretation of a PR's `statusCheckRollup`, shared by `Git::Pr`, `PinnedPr`, `PinnedPRManager` display, `CheckRun` persistence, and `h pr status`:
304
+
305
+ ```ruby
306
+ s = Hiiro::CheckSummary.from_rollup(rollup, truncated: false) # nil when rollup nil/empty
307
+ s = pr.check_summary # from stored counts (+ raw contexts when present)
308
+ s.total / s.success / s.pending / s.failed / s.frozen / s.complete?
309
+ s.red? / s.green? / s.pending? # the -r / -g / -p filter predicates (pr.red? etc. delegate here)
310
+ s.any_pending? / s.only_frozen? # display distinctions
311
+ s.counts_by_outcome # { 'SUCCESS' => 12, 'FAILURE' => 1 } (missing keys read 0)
312
+ s.contexts.each { |c| c.kind (:check_run|:status_context) ; c.name ; c.url ; c.outcome ; c.raw }
313
+ s.to_h # the stored { 'total','success','pending','failed','frozen'[,'truncated'] } hash
314
+ ```
315
+
316
+ ### Hiiro::PullRequestReference / Hiiro::RepositoryIdentity (lib/hiiro/pull_request_reference.rb)
317
+
318
+ ```ruby
319
+ ref = Hiiro::PullRequestReference.parse(input) # URL | '#123' | '123' | 123 → ref, else nil
320
+ ref.number / ref.number_string / ref.repo # repo: RepositoryIdentity or nil for a bare number
321
+ ref.key # [number, 'owner/name'] — the key PinnedPRManager batches by
322
+ ref.repo_args # ['-R', 'owner/name'] or []
323
+ ref.matches?(pr) # same number; same repo when both sides know it
324
+ pr.reference # Git::Pr / PinnedPr → reference
325
+ Hiiro::PinnedPr.find_by_reference(ref) # repo-qualified lookup; legacy rows without repo still match
326
+ Hiiro::RepositoryIdentity.parse('owner/name' | github URL) # .host .owner .name .path .url
327
+ ```
328
+
329
+ ### Hiiro::Git::BranchComparison (lib/hiiro/git/branch_comparison.rb)
330
+
331
+ Two refs plus an explicit base policy. Used by `h branch diff/changed/ahead/behind/log/forkpoint/ancestor`:
332
+
333
+ ```ruby
334
+ cmp = git.compare(source: 'HEAD', target: nil, base_policy: :local_main, fallback: 'HEAD~1')
335
+ # base_policy :local_main → main, master ; :remote_first → origin/master, master, origin/main, main
336
+ cmp.target / cmp.target_source (:explicit|:policy|:fallback|:none) / cmp.resolved?
337
+ cmp.ahead / cmp.behind / cmp.ancestor? / cmp.merge_base / cmp.fork_point (--fork-point, then merge-base)
338
+ cmp.two_dot_range / cmp.three_dot_range / cmp.fork_range / cmp.changed_paths(relative: true)
339
+ ```
340
+
341
+ `Hiiro::Git` gained the underlying queries: `ref_exists?`, `rev_list_count`, `merge_base`, `ancestor?`, `changed_files`.
342
+
343
+ ### Hiiro::TaskLocation (lib/hiiro/task_location.rb)
344
+
345
+ Where a task's directories are. `CurrentTask` and `TaskCli` delegate `code_directory`, `workspace_label`, `tree_path`, `start_directory`, and `app_directory` to it:
346
+
347
+ ```ruby
348
+ loc = Hiiro::TaskLocation.for(task, override: opts[:directory], app: Hiiro::App.new(name:, path:))
349
+ loc.home # TaskRecord.home_for(task.name); never creates it
350
+ loc.worktree_path # task.tree, absolute or under WORK_DIR; nil without a tree
351
+ loc.code_directory # task.primary_directory || worktree_path
352
+ loc.app_directory # app.resolve(code_directory); nil unless both known
353
+ loc.start_directory # override || app_directory || code_directory || home
354
+ loc.source # :override | :app | :primary | :tree | :home (callers run ensure_home only for :home)
355
+ loc.workspace_label # (task.session || task.name).tr('.', '_')
356
+ Hiiro::TaskLocation.contains?(root, path) # realpath containment
357
+ ```
358
+
359
+ ### Hiiro::Herdr::TabCreation / Hiiro::Herdr::Location (lib/hiiro/herdr.rb)
360
+
361
+ `tab create` responses are read through one value instead of `result.dig('tab','tab_id')` at every call site:
362
+
363
+ ```ruby
364
+ created = Hiiro::Herdr::TabCreation.create(client, name:, workspace:, start_directory:, command:, focus:) # or herdr.create_tab(**req)
365
+ created.tab_id / created.pane_id / created.tab / created.root_pane
366
+ created.created? # tab id present created.complete? # tab + root pane present
367
+ created.errors # [] | ['no tab'] | ['no root pane']
368
+ created.location(workspace_id: ws.id) # Herdr::Location
369
+ loc = Hiiro::Herdr::Location.from_meta(meta) # reads herdr_workspace / herdr_tab / herdr_pane
370
+ loc.target # pane_id || tab_id || workspace_id
371
+ loc.to_meta # inverse of from_meta (queue/service metadata)
372
+ ```
373
+
374
+ ### Hiiro::Link / Hiiro::URLTemplate (lib/hiiro/link.rb)
375
+
376
+ `bin/h-link` uses `Hiiro::Link` directly (its inline `LinkManager::Link` copy is gone). `Link.from_hash(yaml_row)` builds an unsaved instance; `to_h` / `to_yaml` give the links.yml shape.
377
+
378
+ ```ruby
379
+ t = Hiiro::URLTemplate.new('https://x/{org}/{repo}', values: { 'org' => 'a b' })
380
+ t.placeholders # ['org', 'repo'] t.placeholders? # true
381
+ t.missing_values # ['repo'] t.complete? # false
382
+ t.url # 'https://x/a+b/{repo}' (spaces → '+', nothing else is encoded)
383
+ ```
384
+
385
+ ### Hiiro::PsProcess::Snapshot / OpenFiles (lib/hiiro/ps_process.rb)
386
+
387
+ ```ruby
388
+ snap = Hiiro::PsProcess::Snapshot.capture # one `ps awwux`; .processes .captured_at
389
+ snap.by_pid(pid) / snap.with_pids(set) / snap.matching(pattern) / snap.parent_of(pid) / snap.children_of(pid)
390
+ of = Hiiro::PsProcess::OpenFiles.for_pid(pid) # one lsof; also .for_port(port), .in_dir(path), for_pid(pid, network_only: true)
391
+ of.pids / of.cwd / of.files / of.sockets / of.failed # failed = lsof exited non-zero (not the same as empty)
392
+ ```
393
+
394
+ `PsProcess.by_port`, `.in_dirs`, `#files`, `#ports`, `#dir`, `#parent`, `#children` keep their shapes and read through these.
395
+
396
+ ### Hiiro::Duration / Hiiro::TimeInput (lib/hiiro/duration.rb, lib/hiiro/time_input.rb)
397
+
398
+ Value objects behind `h date`, `h time`, and `h remind` (formerly duplicated helpers in each bin):
399
+
400
+ ```ruby
401
+ Hiiro::Duration.parse('15m') # => Duration(900s); nil when not <digits><s|m|h|d|w>
402
+ Hiiro::Duration.seconds(90_061).compact # => "1d1h1m1s" (.clock => "25:01:01", .components => {days:,hours:,...})
403
+ Hiiro::Duration.milliseconds(1500) # Rational-precise seconds
404
+
405
+ t = Hiiro::TimeInput.parse('1726000000123') # digits → unix; ms auto-detected past 11 digits (or ms: true)
406
+ t.unit / t.time / t.unix_seconds / t.unix_milliseconds / t.utc / t.iso8601(3)
407
+ Hiiro::TimeInput.parse('now') ; Hiiro::TimeInput.parse('2026-09-22 10:00') # 'now'/blank → now ; else Time.parse
408
+ a.difference(b) # => Duration
409
+ ```
410
+
284
411
  ### Hiiro::Shell (lib/hiiro/shell.rb)
285
412
 
286
413
  Utility for piping content to external commands:
@@ -288,8 +415,16 @@ Utility for piping content to external commands:
288
415
  ```ruby
289
416
  Hiiro::Shell.pipe("content", "pbcopy") # Pipe string to command
290
417
  Hiiro::Shell.pipe_lines(["a", "b"], "command") # Join array with newlines and pipe
418
+ result = Hiiro::Shell.run('cmd', 'arg', SOME_ENV: 'x') # Result: stdout, stderr, status, success?, lines, plain_text
419
+ result.spec # Hiiro::CommandSpecification — argv, env (normalized to strings), cwd, preview, open3_args
420
+ result.command # spec.preview, e.g. "cmd arg"
421
+ content = Hiiro::Shell.read_input(*args) # files named in args ('-' = stdin), else piped stdin
291
422
  ```
292
423
 
424
+ `read_input` keeps only args that are readable paths (regular files, `/dev/fd/N`, `<(cmd)`) or `-`, concatenates them, and falls back to stdin when none remain. A named file wins over piped stdin unless `-` places it. At a terminal with no input it aborts with `no input: pipe data in or pass a file`.
425
+
426
+ `run`, `run_combined`, `run3`, `stream`, and `stream_combined` all build one `CommandSpecification` and attach it to the `Result`.
427
+
293
428
  ### Hiiro::Options (lib/hiiro/options.rb)
294
429
 
295
430
  Argument parsing with flag and option support:
@@ -304,6 +439,8 @@ opts.verbose # true if --verbose or -v was passed
304
439
  opts.args # Remaining non-option arguments
305
440
  ```
306
441
 
442
+ The declared options live in a `Hiiro::OptionSet` (`options.option_set`), which both `Options` and the parsed `Args` use for `help_text`, `hint`, `definition_for('--out' | '-o' | :out)`, `defaults`, `subset(names)`, and `conflicts_for(name)` (mutual-exclusion star topology).
443
+
307
444
  ### Hiiro::Notification (lib/hiiro/notification.rb)
308
445
 
309
446
  macOS notification wrapper using terminal-notifier:
@@ -334,7 +471,9 @@ Subcommands (`h queue <subcmd>`):
334
471
  Config: `~/.config/hiiro/queue/{wip,pending,running,done,failed}/`
335
472
 
336
473
  Key internals:
337
- - `Queue::Prompt` - Parses frontmatter to resolve task/tree/session for working directory
474
+ - `Queue::Entry` - `Entry.find(dirs, name)` / `Entry.in(dirs, status)`; answers `prompt_path`, `meta_path`, `launcher_path`, `body_path`, `companion_files`, `modified_at`, `meta`, `preview`, `prompt`, `execution`, `moved_to(status)`, `to_h` ({ name:, status: }). `Queue#entry_for(name, status)` builds one; `tasks_in`, `all_tasks`, `meta_for`, `task_preview`, `find_task` are thin wrappers over it.
475
+ - `Queue::Execution` - snapshot of a running entry's `.meta`: `started_at`, `elapsed` (Duration), `elapsed_minutes`, `working_directory`, `location` (Herdr::Location), `attach_target`
476
+ - `Queue::Prompt` - Parses frontmatter to resolve task/tree/session for working directory; retains `path`; `body` strips frontmatter; `Prompt.frontmatter_lines(task_info:, ignore:, hints:)` is the editor template shared by add/wip
338
477
  - Tasks launch in tmux windows within the task's session (from frontmatter) or default `hq` session
339
478
  - Launcher script runs `cat prompt | claude`, then moves files to done/failed based on exit code
340
479
 
@@ -386,6 +525,9 @@ my-stack:
386
525
  ```
387
526
 
388
527
  Key internals:
528
+ - `Hiiro::ServiceDefinition` (lib/hiiro/service_definition.rb) - `sm.definition_for(name)`; answers `base_directory`, `host`, `port`, `url`, `init_commands`, `start_command`, `stop_command`, `cleanup_commands`, `environment_files`
529
+ - `Hiiro::EnvironmentFile` - one env file of a service: `template_path`, `destination_path`, `variables` ({ VAR => { variation => value } }), `selected_variation(var, overrides)` (default `local`), `effective_values(overrides)`, `desired_content(existing_lines, overrides)`. `h service env` displays it and `prepare_env` writes it; both read the same object.
530
+ - `Hiiro::ServiceGroup` - `sm.group_definition_for(name)` / `ServiceGroup.from_config(name, cfg)`; `service_names`, `overrides_for(service_name)`
389
531
  - `prepare_env(svc_name, variation_overrides:)` - Copies base_env template from `~/.config/hiiro/env_templates/` to `base_dir/env_file`, then injects variation values
390
532
  - `find_group(name)` / `start_group(name, ...)` - Detect and start service groups, applying per-member `use:` overrides
391
533
  - Default variation is `local` when not specified
data/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.387.pre.1] - 2026-10-01
4
+
5
+ ### Added
6
+ - `Hiiro::Extractor` value object for data extraction
7
+ - Support for Integer return values as exit codes from subcommand handlers
8
+
3
9
  ## Unreleased
4
10
 
5
11
  ### Added
data/bin/h-branch CHANGED
@@ -235,12 +235,8 @@ Hiiro.run(*ARGV) do
235
235
  selected, status = Open3.capture2('sk', '--no-sort', stdin_data: lines.keys.join("\n"))
236
236
  return nil unless status.success? && !selected.strip.empty?
237
237
  lines[selected.chomp]
238
- elsif arg.to_s =~ %r{/pull/(\d+)}
239
- num = $1
240
- pinned.find { |pr| pr.number.to_s == num }
241
- elsif arg.to_s =~ /^#?(\d+)$/
242
- num = $1
243
- pinned.find { |pr| pr.number.to_s == num }
238
+ elsif (ref = Hiiro::PullRequestReference.parse(arg))
239
+ pinned.find { |pr| ref.matches?(pr) }
244
240
  end
245
241
  }
246
242
 
@@ -857,105 +853,51 @@ Hiiro.run(*ARGV) do
857
853
  }
858
854
 
859
855
  add_subcmd(:diff) { |*diff_args|
860
- case diff_args.length
861
- when 0
862
- base = %w[main master].find { |b| system("git rev-parse --verify #{b} >/dev/null 2>&1") } || 'HEAD~1'
863
- range = "#{base}..HEAD"
864
- when 1
865
- range = "#{diff_args[0]}..HEAD"
866
- else
867
- from, to = diff_args[0..1]
868
- range = "#{from}..#{to}"
869
- end
870
-
871
- system('git', 'log', '--oneline', '--decorate', range)
856
+ cmp = git.compare(source: diff_args[1] || 'HEAD', target: diff_args[0], base_policy: :local_main, fallback: 'HEAD~1')
857
+ system('git', 'log', '--oneline', '--decorate', cmp.two_dot_range)
872
858
  }
873
859
 
874
860
  add_subcmd(:changed) { |*changed_args|
875
861
  show_all = changed_args.delete('-a') || changed_args.delete('--all')
876
- upstream = changed_args.first
862
+ cmp = git.compare(target: changed_args.first, base_policy: :remote_first)
877
863
 
878
- upstream ||= %w[origin/master master origin/main main].find { |ref|
879
- system("git rev-parse --verify #{ref} >/dev/null 2>&1")
880
- }
881
-
882
- unless upstream
864
+ unless cmp.resolved?
883
865
  puts "Cannot find master or origin/master"
884
866
  next
885
867
  end
886
868
 
887
- forkpoint = `git merge-base --fork-point #{upstream} HEAD 2>/dev/null`.strip
888
- if forkpoint.empty?
889
- forkpoint = `git merge-base #{upstream} HEAD 2>/dev/null`.strip
890
- end
891
-
892
- if forkpoint.empty?
869
+ unless cmp.fork_point
893
870
  puts "Could not find fork point"
894
871
  next
895
872
  end
896
873
 
897
- if show_all
898
- system('git', 'diff', '--name-only', "#{forkpoint}...HEAD")
899
- else
900
- system('git', 'diff', '--name-only', '--relative', "#{forkpoint}...HEAD")
901
- end
874
+ puts cmp.changed_paths(relative: !show_all)
902
875
  }
903
876
 
904
877
  add_subcmd(:ahead) { |*ahead_args|
905
- case ahead_args.length
906
- when 0
907
- base = %w[main master].find { |b| system("git rev-parse --verify #{b} >/dev/null 2>&1") } || 'HEAD~1'
908
- ahead_of = base
909
- branch = 'HEAD'
910
- when 1
911
- ahead_of = ahead_args[0]
912
- branch = 'HEAD'
913
- else
914
- ahead_of, branch = ahead_args[0..1]
915
- end
916
-
917
- count = `git rev-list --count #{ahead_of}..#{branch}`.strip.to_i
918
- puts "#{branch} is #{count} commit(s) ahead of #{ahead_of}"
878
+ cmp = git.compare(source: ahead_args[1] || 'HEAD', target: ahead_args[0], base_policy: :local_main, fallback: 'HEAD~1')
879
+ puts "#{cmp.source} is #{cmp.ahead} commit(s) ahead of #{cmp.target}"
919
880
  }
920
881
 
921
882
  add_subcmd(:behind) { |*behind_args|
922
- case behind_args.length
923
- when 0
924
- base = %w[main master].find { |b| system("git rev-parse --verify #{b} >/dev/null 2>&1") } || 'HEAD~1'
925
- behind_of = base
926
- branch = 'HEAD'
927
- when 1
928
- behind_of = behind_args[0]
929
- branch = 'HEAD'
930
- else
931
- behind_of, branch = behind_args[0..1]
932
- end
933
-
934
- count = `git rev-list --count #{branch}..#{behind_of}`.strip.to_i
935
- puts "#{branch} is #{count} commit(s) behind #{behind_of}"
883
+ cmp = git.compare(source: behind_args[1] || 'HEAD', target: behind_args[0], base_policy: :local_main, fallback: 'HEAD~1')
884
+ puts "#{cmp.source} is #{cmp.behind} commit(s) behind #{cmp.target}"
936
885
  }
937
886
 
938
887
  add_subcmd(:log) { |upstream = nil|
939
- upstream ||= %w[origin/master master origin/main main].find { |ref|
940
- system("git rev-parse --verify #{ref} >/dev/null 2>&1")
941
- }
888
+ cmp = git.compare(target: upstream, base_policy: :remote_first)
942
889
 
943
- unless upstream
890
+ unless cmp.resolved?
944
891
  puts "Cannot find master or origin/master"
945
892
  next
946
893
  end
947
894
 
948
- forkpoint = `git merge-base --fork-point #{upstream} HEAD 2>/dev/null`.strip
949
- if forkpoint.empty?
950
- forkpoint = `git merge-base #{upstream} HEAD 2>/dev/null`.strip
951
- end
952
-
953
- if forkpoint.empty?
895
+ unless cmp.fork_point
954
896
  puts "Could not find fork point"
955
897
  next
956
898
  end
957
899
 
958
- system('git', 'log', '--oneline', '--decorate', "#{forkpoint}..HEAD")
900
+ system('git', 'log', '--oneline', '--decorate', cmp.fork_range)
959
901
  }
960
902
 
961
903
  add_subcmd(:q, :query) { |*args|
@@ -1001,50 +943,32 @@ Hiiro.run(*ARGV) do
1001
943
  }
1002
944
 
1003
945
  add_subcmd(:forkpoint) { |upstream = nil, branch = nil|
1004
- branch ||= 'HEAD'
1005
- upstream ||= %w[origin/master master origin/main main].find { |ref|
1006
- system("git rev-parse --verify #{ref} >/dev/null 2>&1")
1007
- }
946
+ cmp = git.compare(source: branch || 'HEAD', target: upstream, base_policy: :remote_first)
1008
947
 
1009
- unless upstream
948
+ unless cmp.resolved?
1010
949
  puts "Cannot find master or origin/master"
1011
950
  next
1012
951
  end
1013
952
 
1014
- forkpoint = `git merge-base --fork-point #{upstream} #{branch} 2>/dev/null`.strip
1015
-
1016
- if forkpoint.empty?
1017
- forkpoint = `git merge-base #{upstream} #{branch} 2>/dev/null`.strip
1018
- end
1019
-
1020
- if forkpoint.empty?
1021
- puts "Could not find fork point between #{upstream} and #{branch}"
953
+ if cmp.fork_point
954
+ puts cmp.fork_point
1022
955
  else
1023
- puts forkpoint
956
+ puts "Could not find fork point between #{cmp.target} and #{cmp.source}"
1024
957
  end
1025
958
  }
1026
959
 
1027
960
  add_subcmd(:ancestor) { |*ancestor_args|
1028
- case ancestor_args.length
1029
- when 0
1030
- base = %w[main master].find { |b| system("git rev-parse --verify #{b} >/dev/null 2>&1") }
1031
- unless base
1032
- puts "Cannot find main or master branch"
1033
- next
1034
- end
1035
- ancestor = base
1036
- descendant = 'HEAD'
1037
- when 1
1038
- ancestor = ancestor_args[0]
1039
- descendant = 'HEAD'
1040
- else
1041
- ancestor, descendant = ancestor_args[0..1]
961
+ cmp = git.compare(source: ancestor_args[1] || 'HEAD', target: ancestor_args[0], base_policy: :local_main)
962
+
963
+ unless cmp.resolved?
964
+ puts "Cannot find main or master branch"
965
+ next
1042
966
  end
1043
967
 
1044
- if system("git merge-base --is-ancestor #{ancestor} #{descendant}")
1045
- puts "Yes, #{ancestor} is an ancestor of #{descendant}"
968
+ if cmp.ancestor?
969
+ puts "Yes, #{cmp.target} is an ancestor of #{cmp.source}"
1046
970
  else
1047
- puts "No, #{ancestor} is NOT an ancestor of #{descendant}"
971
+ puts "No, #{cmp.target} is NOT an ancestor of #{cmp.source}"
1048
972
  end
1049
973
  }
1050
974
  end
data/bin/h-date CHANGED
@@ -3,52 +3,20 @@
3
3
  require 'hiiro'
4
4
  require 'time'
5
5
 
6
- # Parse a timestamp string as unix seconds (10-digit) or milliseconds (13-digit)
7
- def parse_unix(str, ms: nil)
8
- n = Rational(str.to_s)
9
- ms = str.to_s.sub(/\..*/, '').length > 11 if ms.nil?
10
- Time.at(ms ? n / 1000 : n)
11
- end
12
-
13
- def parse_date(str)
14
- str.nil? || str.empty? || str == 'now' ? Time.now : Time.parse(str)
15
- end
16
-
17
- # Parse a value that is either a unix timestamp (digits) or a date string.
18
- def parse_date_or_unix(str, ms: false)
19
- str.to_s =~ /^-?\d+(\.\d+)?$/ ? parse_unix(str, ms: ms ? true : nil) : parse_date(str)
20
- end
21
-
22
- # Format a number of seconds as a compact human duration: 1d23h12m10s
23
- def human_duration(total_secs)
24
- total = total_secs.to_f
25
- sign = total.negative? ? '-' : ''
26
- total = total.abs.round
27
- days, rem = total.divmod(86_400)
28
- hours, rem = rem.divmod(3_600)
29
- minutes, secs = rem.divmod(60)
30
- parts = []
31
- parts << "#{days}d" if days > 0
32
- parts << "#{hours}h" if hours > 0
33
- parts << "#{minutes}m" if minutes > 0
34
- parts << "#{secs}s" if secs > 0
35
- sign + (parts.empty? ? '0s' : parts.join)
36
- end
37
-
38
6
  Hiiro.run(*ARGV) do
39
7
  add_flag(:ms, short: :m, desc: 'Use milliseconds instead of seconds')
40
8
  add_flag(:utc, short: :u, desc: 'Output in UTC')
41
9
 
42
10
  # h date to_unix [date-string] -> unix seconds (or ms with --ms)
43
11
  add_cmd(:to_unix, :unix, :ts, opts: %i[ms]) do
44
- t = parse_date(@opts.args.join(' '))
45
- puts @opts.ms ? (t.to_f * 1000).round : t.to_i
12
+ t = Hiiro::TimeInput.parse(@opts.args.join(' '))
13
+ puts @opts.ms ? t.unix_milliseconds : t.unix_seconds
46
14
  end
47
15
 
48
16
  # h date from_unix <timestamp> -> human date (auto-detects ms by digit count)
49
17
  add_cmd(:from_unix, :parse, opts: %i[ms utc]) do
50
18
  raw = @opts.args.first or abort 'usage: h date from_unix <timestamp> [--ms] [--utc]'
51
- t = parse_unix(raw, ms: @opts.ms ? true : nil)
19
+ t = Hiiro::TimeInput.parse(raw, ms: @opts.ms ? true : nil)
52
20
  t = t.utc if @opts.utc
53
21
  puts t.iso8601(3)
54
22
  end
@@ -71,15 +39,15 @@ Hiiro.run(*ARGV) do
71
39
  add_cmd(:dur, :duration, :human, opts: %i[ms]) do
72
40
  raw = @opts.args.first or abort 'usage: h date dur <seconds> [--ms]'
73
41
  n = Rational(raw.to_s)
74
- puts human_duration(@opts.ms ? n / 1000 : n)
42
+ puts (@opts.ms ? Hiiro::Duration.milliseconds(n) : Hiiro::Duration.seconds(n)).compact
75
43
  end
76
44
 
77
45
  # h date diff <a> <b> -> how far apart two dates are (dates or unix timestamps)
78
46
  add_cmd(:diff, opts: %i[ms]) do
79
47
  a, b = @opts.args
80
48
  abort 'usage: h date diff <date-a> <date-b> (each: date string or unix timestamp)' unless a && b
81
- ta = parse_date_or_unix(a, ms: @opts.ms)
82
- tb = parse_date_or_unix(b, ms: @opts.ms)
83
- puts human_duration(tb - ta)
49
+ ta = Hiiro::TimeInput.parse(a, ms: @opts.ms ? true : nil)
50
+ tb = Hiiro::TimeInput.parse(b, ms: @opts.ms ? true : nil)
51
+ puts ta.difference(tb).compact
84
52
  end
85
53
  end
data/bin/h-docs CHANGED
@@ -8,6 +8,8 @@ require 'uri'
8
8
  SERVER = ENV.fetch('HDOCS_SERVER', 'http://127.0.0.1:4387').sub(%r{/$}, '')
9
9
  DOCS_DIR = File.join(Dir.home, 'claude', 'docs')
10
10
  PROJECT_ROOT = ENV.fetch('PORTFOLIO_HOME', File.join(Dir.home, 'proj', 'portfolio'))
11
+ DESKTOP_DIR = File.join(PROJECT_ROOT, 'apps/desktop')
12
+ APP_PATH = File.join(DESKTOP_DIR, 'bin/desktop.app')
11
13
  SERVICE_LABEL = "com.#{ENV.fetch('USER')}.portfolio"
12
14
  SERVICE_DOMAIN = "gui/#{Process.uid}"
13
15
  SERVICE_TARGET = "#{SERVICE_DOMAIN}/#{SERVICE_LABEL}"
@@ -45,6 +47,10 @@ class PortfolioApi
45
47
  get('/health')
46
48
  end
47
49
 
50
+ def ports
51
+ get('/api/ports')
52
+ end
53
+
48
54
  def delete(id, files: false)
49
55
  target = uri("/api/items/#{id}")
50
56
  target.query = URI.encode_www_form(delete_files: 1) if files
@@ -188,6 +194,15 @@ Hiiro.run(*ARGV, plugins: [Pins]) {
188
194
  matching_items(opts, limit: count).each { |item| puts item_label(item) }
189
195
  }
190
196
 
197
+ add_subcmd(:make) { |*args|
198
+ Dir.chdir DESKTOP_DIR
199
+ system 'wails3', 'package'
200
+ }
201
+
202
+ add_subcmd(:run) { |*args|
203
+ system 'open', APP_PATH
204
+ }
205
+
191
206
  add_subcmd(:open) { |*args|
192
207
  opts = open_opts.parse(args)
193
208
  if opts.args.empty? && !opts.html && !opts.md && !opts.all
@@ -245,4 +260,27 @@ Hiiro.run(*ARGV, plugins: [Pins]) {
245
260
  add_subcmd(:path) { |*_args|
246
261
  puts DOCS_DIR
247
262
  }
263
+
264
+ add_subcmd(:ports) { |*args|
265
+ json = args.include?('--json')
266
+ snapshot = API.ports
267
+ if json
268
+ next puts JSON.pretty_generate(snapshot)
269
+ end
270
+ ports = snapshot.fetch('ports', [])
271
+ if ports.empty?
272
+ next puts('No TCP listeners found.')
273
+ end
274
+ puts format('%-7s %-15s %-18s %-10s %s', 'PORT', 'BIND', 'PROCESS', 'UPTIME', 'DIRECTORY / HERDR / AI')
275
+ ports.each do |port|
276
+ herdr = port['herdr']
277
+ ai = port['ai']
278
+ herdr_label = herdr ? "#{herdr['workspace_label'] || herdr['workspace_id']} #{herdr['pane_id']}#{herdr['match'] == 'cwd' ? ' ~' : ''}" : '-'
279
+ ai_label = ai&.fetch('session', nil) ? "#{ai['kind']} #{ai['session'][0, 8]}…" : (ai&.fetch('kind', nil) || '-')
280
+ uptime = port['elapsed'] && !port['elapsed'].empty? ? port['elapsed'] : '-'
281
+ puts format('%-7s %-15s %-18s %-10s %s', port['port'], port['host'], "#{port['command']}##{port['pid']}", uptime, port['cwd'].to_s)
282
+ puts " herdr: #{herdr_label} ai: #{ai_label}"
283
+ end
284
+ warn "herdr unavailable: #{snapshot.fetch('warnings', []).join('; ')}" if snapshot['herdr_available'] == false
285
+ }
248
286
  }
data/bin/h-link CHANGED
@@ -22,55 +22,8 @@ class LinkManager
22
22
  'created_at' => Time.now.iso8601,
23
23
  }
24
24
 
25
- class Link
26
- attr_accessor :url, :description, :shorthand, :created_at
27
-
28
- def initialize(url:, description: '', shorthand: nil, created_at: Time.now.iso8601)
29
- @url = url
30
- @description = description
31
- @shorthand = shorthand
32
- @created_at = created_at
33
- end
34
-
35
- def self.from_hash(hash)
36
- new(
37
- url: hash['url'],
38
- description: hash['description'] || '',
39
- shorthand: hash['shorthand'],
40
- created_at: hash['created_at'] || Time.now.iso8601
41
- )
42
- end
43
-
44
- def to_h
45
- {
46
- 'url' => @url,
47
- 'description' => @description,
48
- 'shorthand' => @shorthand,
49
- 'created_at' => @created_at
50
- }
51
- end
52
-
53
- def to_yaml
54
- to_h.to_yaml
55
- end
56
-
57
- def matches?(*terms)
58
- searchable = [
59
- @url,
60
- @description,
61
- @shorthand
62
- ].compact.join(' ').downcase
63
-
64
- terms.all? { |term| searchable.downcase.include?(term.downcase) }
65
- end
66
-
67
- def display_string(index = nil)
68
- num = index ? "#{(index + 1).to_s.rjust(3)}." : ""
69
- shorthand_str = @shorthand ? " [#{@shorthand}]" : ""
70
- desc_str = @description.to_s.empty? ? "" : " - #{@description}"
71
- "#{num}#{shorthand_str} #{@url}#{desc_str}".strip
72
- end
73
- end
25
+ # Shared representation with the DB model; YAML rows load as unsaved instances.
26
+ Link = Hiiro::Link
74
27
 
75
28
  attr_reader :links_file
76
29
 
@@ -172,11 +125,11 @@ class LinkManager
172
125
  end
173
126
 
174
127
  def extract_placeholders(url)
175
- url.scan(/\{(\w+)\}/).flatten
128
+ Hiiro::URLTemplate.new(url).placeholders
176
129
  end
177
130
 
178
131
  def has_placeholders?(url)
179
- !extract_placeholders(url).empty?
132
+ Hiiro::URLTemplate.new(url).placeholders?
180
133
  end
181
134
 
182
135
  def prompt_for_placeholder_values(url, link=nil)
@@ -212,13 +165,7 @@ class LinkManager
212
165
  end
213
166
 
214
167
  def substitute_placeholders(url, values)
215
- result = url.dup
216
- values.each do |key, value|
217
- # Replace spaces with + for URL encoding
218
- encoded_value = value.to_s.gsub(' ', '+')
219
- result.gsub!("{#{key}}", encoded_value)
220
- end
221
- result
168
+ Hiiro::URLTemplate.new(url, values: values).url
222
169
  end
223
170
 
224
171
  def find_link_by_ref(ref, links)