hiiro 0.1.388 → 0.1.389
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/README.md +44 -0
- data/bin/h-extract +61 -0
- data/bin/h-mcp +310 -272
- data/docs/README.md +1 -0
- data/docs/h.md +1 -0
- data/lib/hiiro/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7702a9aa5a7b32c1f0038e95551844d0da347fcaebeaf84ae074190839f35b56
|
|
4
|
+
data.tar.gz: 06e1c1ce29d001051f5b8190a36163577c75c5a38eacc6407b49c305f62ec944
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c595c3d03f9effac1ea185d1307a56116c08abf981a045a2b3a9de7108c3b63902b8de08664bfaec3f726f30319a34c8469b9e23c5783619cee36ab17bc9fe8a
|
|
7
|
+
data.tar.gz: ebd3be17857932e7ab261beb8635c65a9b7e802058dd5d653753770665571ad097c6b179f03d04a12b9dd3b67bb1b1a98b89ecf2ba58723f87419504c1e3e024
|
data/README.md
CHANGED
|
@@ -73,6 +73,49 @@ Saved files can be listed, printed, copied back to the clipboard, opened,
|
|
|
73
73
|
edited, or removed with `h save ls|show|copy|open|edit|rm`. See
|
|
74
74
|
[docs/h-save.md](docs/h-save.md).
|
|
75
75
|
|
|
76
|
+
### Extract files, directories, and links
|
|
77
|
+
|
|
78
|
+
`h extract files`, `h extract dirs`, and `h extract links` read text from stdin
|
|
79
|
+
or input files and print one unique match per line, in first-encounter order.
|
|
80
|
+
|
|
81
|
+
```sh
|
|
82
|
+
# Read text from stdin
|
|
83
|
+
printf '%s\n' 'README.md:12:3' 'README.md' | h extract files
|
|
84
|
+
printf '%s\n' 'docs/' './docs/' | h extract dirs
|
|
85
|
+
printf '%s\n' '(https://example.org/a_(b)).' | h extract links
|
|
86
|
+
|
|
87
|
+
# Read text from input files
|
|
88
|
+
h extract files build.log
|
|
89
|
+
h extract dirs build.log
|
|
90
|
+
h extract links notes.txt build.log
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Files and directories must exist and match the requested type. Relative paths
|
|
94
|
+
are checked against the current directory by default. Repeat `--base-dir DIR`
|
|
95
|
+
or `-b DIR` to replace that default with explicit bases. A match under any one
|
|
96
|
+
base is enough:
|
|
97
|
+
|
|
98
|
+
```sh
|
|
99
|
+
h extract files --base-dir /path/to/project -b /path/to/other-project build.log
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Base directories affect path checks, not the locations of input files. Output
|
|
103
|
+
preserves the original path spelling rather than expanding or normalizing it.
|
|
104
|
+
Quotes and surrounding wrappers are removed, as are `:line` and `:line:column`
|
|
105
|
+
suffixes. Quoted paths can contain spaces, such as `"src/my file.rb:12:3"`.
|
|
106
|
+
Links must use HTTP or HTTPS. Trailing punctuation is removed, while balanced
|
|
107
|
+
parentheses in URLs are kept.
|
|
108
|
+
|
|
109
|
+
`--no-check` skips filesystem checks and uses text heuristics, not actual
|
|
110
|
+
existence or type. Files need a slash or a dotted filename and cannot end in a
|
|
111
|
+
slash. Directories need a slash and cannot have a dotted basename. These rules
|
|
112
|
+
can misclassify paths or omit real files and directories:
|
|
113
|
+
|
|
114
|
+
```sh
|
|
115
|
+
printf '%s\n' '"src/my file.rb:12:3"' | h extract files --no-check
|
|
116
|
+
printf '%s\n' 'missing/output/' | h extract dirs --no-check
|
|
117
|
+
```
|
|
118
|
+
|
|
76
119
|
### Task CLI
|
|
77
120
|
|
|
78
121
|
`t` and `tt` are gem executables in `exe/`, installed alongside `h`. They are thin
|
|
@@ -167,6 +210,7 @@ See the [task command reference](docs/t.md) for all commands and the [workflow i
|
|
|
167
210
|
| `h commit` | Select commits using fuzzy finder |
|
|
168
211
|
| `h config` | Open config files (vim, git, Herdr, zsh, starship, claude) |
|
|
169
212
|
| `h env` | Append safely quoted environment variables |
|
|
213
|
+
| [`h extract`](#extract-files-directories-and-links) | Extract file paths, directories, and HTTP(S) links from text |
|
|
170
214
|
| `h link` | Manage saved links with URL, description, and shorthand |
|
|
171
215
|
| `h pane` | Herdr pane management |
|
|
172
216
|
| `h plugin` | Manage hiiro plugins (list, edit, search) |
|
data/bin/h-extract
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
|
|
3
|
+
require 'hiiro'
|
|
4
|
+
|
|
5
|
+
options = Hiiro::Options.setup do
|
|
6
|
+
option(:base_dir, short: :b, multi: true, desc: 'Base directory for relative path checks (repeatable; default: cwd)')
|
|
7
|
+
flag(:no_check, desc: 'Extract path-shaped text without checking the filesystem')
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
clean_url = lambda do |url|
|
|
11
|
+
url = url.sub(/[.,;:!?]+\z/, '')
|
|
12
|
+
{ ')' => '(', ']' => '[', '}' => '{' }.each do |closing, opening|
|
|
13
|
+
url = url.chop while url.end_with?(closing) && url.count(closing) > url.count(opening)
|
|
14
|
+
end
|
|
15
|
+
url.sub(/[.,;:!?]+\z/, '')
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
Hiiro.run(*ARGV) do
|
|
19
|
+
%i[files dirs links].each do |kind|
|
|
20
|
+
add_subcmd(kind) do |*args|
|
|
21
|
+
vals = options.parse!(args)
|
|
22
|
+
if vals.help?
|
|
23
|
+
puts "Usage: h extract #{kind} [options] [input-file ...]"
|
|
24
|
+
puts options.help_text
|
|
25
|
+
next
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
abort("--base-dir requires a directory") if vals.base_dir.any? { |base| base.nil? || base.empty? }
|
|
29
|
+
bases = vals.base_dir.empty? ? [Dir.pwd] : vals.base_dir.map { |base| File.expand_path(base) }
|
|
30
|
+
input = Hiiro::Shell.read_input(*vals.args)
|
|
31
|
+
matches = if kind == :links
|
|
32
|
+
input.scan(%r{https?://[^\s<>"'`]+}i).map { |url| clean_url.call(url) }
|
|
33
|
+
else
|
|
34
|
+
# Consume URLs whole so their slash-separated pieces cannot become paths.
|
|
35
|
+
tokens = input.scan(%r{https?://[^\s<>"'`]+|(?<![[:alnum:]])(?:"[^"\n]+"|'[^'\n]+'|`[^`\n]+`)|[^\s<>"'`()\[\]{},;=]+}i)
|
|
36
|
+
tokens.filter_map do |token|
|
|
37
|
+
quoted = ['"', "'", '`'].include?(token[0])
|
|
38
|
+
original = quoted ? token[1...-1] : token
|
|
39
|
+
path = original.sub(/[.!?:]+\z/, '').sub(/:\d+(?::\d+)?\z/, '')
|
|
40
|
+
next if path.empty? || path.match?(%r{\A[a-z][a-z0-9+.-]*://}i)
|
|
41
|
+
|
|
42
|
+
if vals.no_check
|
|
43
|
+
next unless path.include?('/') || path.match?(/\A[^\s]+\.[^\s.]+\z/)
|
|
44
|
+
next if kind == :files && path.end_with?('/')
|
|
45
|
+
next if kind == :dirs && File.basename(path).match?(/\.[^.]+\z/)
|
|
46
|
+
else
|
|
47
|
+
path = [original, path].uniq.find do |candidate|
|
|
48
|
+
bases.any? do |base|
|
|
49
|
+
full_path = File.expand_path(candidate, base)
|
|
50
|
+
kind == :files ? File.file?(full_path) : File.directory?(full_path)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
next unless path
|
|
54
|
+
end
|
|
55
|
+
path
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
puts matches.uniq
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
data/bin/h-mcp
CHANGED
|
@@ -1,29 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
# Subcommands:
|
|
7
|
-
# h mcp list servers served by the registry
|
|
8
|
-
# h mcp ui open the registry web UI
|
|
9
|
-
# h mcp sync <agent> write registry servers into an agent's MCP config
|
|
10
|
-
# h mcp status drift check: registry vs each agent's config
|
|
11
|
-
# h mcp test <name> MCP initialize handshake through the proxy
|
|
12
|
-
# h mcp url <name> print a server's local proxy URL
|
|
13
|
-
# h mcp info <name> full registry record for a server
|
|
14
|
-
# h mcp agents known sync targets and their config paths
|
|
15
|
-
#
|
|
16
|
-
# sync targets: omp, codex, claude, cursor, vscode, gemini, windsurf, opencode
|
|
17
|
-
# Unknown targets fall back to running `omp` headless (web search allowed) to
|
|
18
|
-
# research that agent's MCP config format and apply it.
|
|
19
|
-
|
|
4
|
+
# Local MCP registry, SDK commands, and HTTP-only legacy client sync.
|
|
20
5
|
require 'hiiro'
|
|
21
6
|
require 'json'
|
|
22
|
-
require 'net/http'
|
|
23
7
|
require 'uri'
|
|
8
|
+
require 'digest'
|
|
24
9
|
require 'fileutils'
|
|
10
|
+
require 'tempfile'
|
|
11
|
+
require_relative 'lib/h_mcp_catalog'
|
|
25
12
|
|
|
26
|
-
DEFAULT_ENDPOINT = 'http://localhost:15245/api/servers'
|
|
27
13
|
OMP_SCHEMA = 'https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/src/config/mcp-schema.json'
|
|
28
14
|
HTTP_ENTRY = ->(url) { { 'type' => 'http', 'url' => url } }
|
|
29
15
|
|
|
@@ -33,20 +19,12 @@ AGENT_TARGETS = {
|
|
|
33
19
|
'codex' => ->(home) { [File.join(home, '.codex', 'config.toml'), nil, nil] },
|
|
34
20
|
'claude' => ->(home) { [File.join(home, '.claude.json'), ['mcpServers'], HTTP_ENTRY] },
|
|
35
21
|
'cursor' => ->(home) { [File.join(home, '.cursor', 'mcp.json'), ['mcpServers'], ->(u) { { 'url' => u } }] },
|
|
36
|
-
'vscode' => ->(_home) { [nil, ['servers'], HTTP_ENTRY] },
|
|
22
|
+
'vscode' => ->(_home) { [nil, ['servers'], HTTP_ENTRY] },
|
|
37
23
|
'gemini' => ->(home) { [File.join(home, '.gemini', 'settings.json'), ['mcpServers'], ->(u) { { 'httpUrl' => u } }] },
|
|
38
24
|
'windsurf' => ->(home) { [File.join(home, '.codeium', 'windsurf', 'mcp_config.json'), ['mcpServers'], ->(u) { { 'serverUrl' => u } }] },
|
|
39
25
|
'opencode' => ->(home) { [File.join(home, '.config', 'opencode', 'opencode.json'), ['mcp'], ->(u) { { 'type' => 'remote', 'url' => u, 'enabled' => true } }] },
|
|
40
26
|
}.freeze
|
|
41
27
|
|
|
42
|
-
def fetch_registry(endpoint)
|
|
43
|
-
uri = URI.parse(endpoint)
|
|
44
|
-
res = Net::HTTP.get_response(uri)
|
|
45
|
-
raise "endpoint #{endpoint} returned HTTP #{res.code}" unless res.is_a?(Net::HTTPSuccess)
|
|
46
|
-
JSON.parse(res.body, symbolize_names: true)
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
# agent => project-level config path (relative to cwd); omp/codex/vscode handled inline
|
|
50
28
|
PROJECT_PATHS = {
|
|
51
29
|
'claude' => '.mcp.json',
|
|
52
30
|
'cursor' => '.cursor/mcp.json',
|
|
@@ -55,329 +33,389 @@ PROJECT_PATHS = {
|
|
|
55
33
|
'opencode' => 'opencode.json',
|
|
56
34
|
}.freeze
|
|
57
35
|
|
|
58
|
-
def
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
36
|
+
def local_servers(catalog)
|
|
37
|
+
catalog.registry.each_with_object({}) do |registration, servers|
|
|
38
|
+
next if registration['enabled'] == false
|
|
39
|
+
name = registration.fetch('name')
|
|
40
|
+
server = catalog.resolve(name)
|
|
41
|
+
next unless server['enabled']
|
|
42
|
+
unless server['transport'] == 'http'
|
|
43
|
+
raise %(#{name} uses #{server['transport']}; legacy sync/status supports HTTP URLs only)
|
|
44
|
+
end
|
|
45
|
+
settings = catalog.connection_settings(server)
|
|
46
|
+
uri = URI.parse(settings.fetch(:url))
|
|
47
|
+
raise %(#{name} requires an absolute HTTP or HTTPS URL) unless uri.is_a?(URI::HTTP) && uri.host
|
|
48
|
+
servers[name] = settings
|
|
63
49
|
end
|
|
64
50
|
end
|
|
65
51
|
|
|
66
|
-
def
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
52
|
+
def target_entries(agent, servers, builder)
|
|
53
|
+
servers.to_h do |name, settings|
|
|
54
|
+
if agent == 'codex'
|
|
55
|
+
unless settings.fetch(:headers).empty?
|
|
56
|
+
raise %(codex: #{name} requires HTTP headers; legacy Codex sync supports unauthenticated URL-only settings)
|
|
57
|
+
end
|
|
58
|
+
entry = { 'url' => settings.fetch(:url) }
|
|
59
|
+
else
|
|
60
|
+
entry = builder.call(settings.fetch(:url))
|
|
61
|
+
entry['headers'] = settings.fetch(:headers) unless settings.fetch(:headers).empty?
|
|
62
|
+
end
|
|
63
|
+
[name, entry]
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def export_fingerprint(value)
|
|
68
|
+
canonical = lambda do |item|
|
|
69
|
+
case item
|
|
70
|
+
when Hash then item.keys.sort.to_h { |key| [key, canonical.call(item[key])] }
|
|
71
|
+
when Array then item.map { |entry| canonical.call(entry) }
|
|
72
|
+
else item
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
Digest::SHA256.hexdigest(JSON.generate(canonical.call(value)))
|
|
70
76
|
end
|
|
71
77
|
|
|
72
78
|
def backup(path)
|
|
73
|
-
|
|
79
|
+
return unless File.exist?(path)
|
|
80
|
+
destination = "#{path}.bak-#{Time.now.strftime('%Y%m%d%H%M%S%6N')}"
|
|
81
|
+
FileUtils.cp(path, destination)
|
|
82
|
+
File.chmod(0600, destination)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def write_config(path, content)
|
|
86
|
+
destination = File.symlink?(path) ? File.realpath(path) : path
|
|
87
|
+
FileUtils.mkdir_p(File.dirname(destination))
|
|
88
|
+
Tempfile.create(['.h-mcp-', '.tmp'], File.dirname(destination)) do |file|
|
|
89
|
+
file.write(content)
|
|
90
|
+
file.flush
|
|
91
|
+
file.fsync
|
|
92
|
+
backup(path)
|
|
93
|
+
File.rename(file.path, destination)
|
|
94
|
+
end
|
|
74
95
|
end
|
|
75
96
|
|
|
76
|
-
def sync_json(path:, container_keys:, servers:,
|
|
97
|
+
def sync_json(path:, container_keys:, servers:, store:, opts:, schema: nil)
|
|
77
98
|
data = File.exist?(path) ? JSON.parse(File.read(path)) : {}
|
|
99
|
+
raise %(expected configuration object in #{path}) unless data.is_a?(Hash)
|
|
100
|
+
before = JSON.generate(data)
|
|
78
101
|
data = { '$schema' => schema }.merge(data) if schema && !data.key?('$schema')
|
|
79
|
-
container = container_keys.inject(data)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
102
|
+
container = container_keys.inject(data) do |parent, key|
|
|
103
|
+
raise %(expected server container object in #{path}) unless parent.is_a?(Hash)
|
|
104
|
+
parent[key] = {} unless parent.key?(key)
|
|
105
|
+
parent.fetch(key)
|
|
106
|
+
end
|
|
107
|
+
raise %(expected server container object in #{path}) unless container.is_a?(Hash)
|
|
108
|
+
ownership = store.sync_state(path)
|
|
83
109
|
removed = []
|
|
84
110
|
if opts.prune
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
111
|
+
(ownership.keys - servers.keys).each do |name|
|
|
112
|
+
if !container.key?(name)
|
|
113
|
+
ownership.delete(name)
|
|
114
|
+
elsif export_fingerprint(container[name]) == ownership[name]
|
|
115
|
+
container.delete(name)
|
|
116
|
+
ownership.delete(name)
|
|
117
|
+
removed << name
|
|
118
|
+
end
|
|
90
119
|
end
|
|
91
120
|
end
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
121
|
+
servers.each do |name, entry|
|
|
122
|
+
container[name] = entry
|
|
123
|
+
ownership[name] = export_fingerprint(entry)
|
|
124
|
+
end
|
|
95
125
|
if opts.dry_run
|
|
96
126
|
puts "[dry-run] #{path}"
|
|
97
|
-
puts JSON.pretty_generate(
|
|
98
|
-
elsif changed || !File.exist?(path)
|
|
99
|
-
FileUtils.mkdir_p(File.dirname(path))
|
|
100
|
-
backup(path)
|
|
101
|
-
File.write(path, JSON.pretty_generate(data) + "\n")
|
|
102
|
-
puts "wrote #{path}"
|
|
127
|
+
puts JSON.pretty_generate(data)
|
|
103
128
|
else
|
|
104
|
-
|
|
129
|
+
if !File.exist?(path) || JSON.generate(data) != before
|
|
130
|
+
write_config(path, JSON.pretty_generate(data) + "\n")
|
|
131
|
+
puts "wrote #{path}"
|
|
132
|
+
else
|
|
133
|
+
puts "unchanged #{path}"
|
|
134
|
+
end
|
|
135
|
+
store.save_sync_state(path, ownership)
|
|
105
136
|
end
|
|
106
|
-
puts " servers: #{
|
|
137
|
+
puts " servers: #{container.keys.sort.join(', ')}"
|
|
107
138
|
puts " pruned: #{removed.join(', ')}" unless removed.empty?
|
|
108
139
|
end
|
|
109
140
|
|
|
141
|
+
TOML_KEY = /"(?:[^"\\\r\n]|\\.)*"|'[^'\r\n]*'|[A-Za-z0-9_-]+/
|
|
142
|
+
CODEX_URL_LINE = /\A([ \t]*url[ \t]*=[ \t]*)("(?:[^"\\\r\n]|\\.)*"|'[^'\r\n]*')([ \t]*(?:#[^\r\n]*)?(?:\r?\n)?)\z/
|
|
143
|
+
|
|
110
144
|
def toml_key(name)
|
|
111
|
-
name
|
|
145
|
+
name.match?(/\A[A-Za-z0-9_-]+\z/) ? name : name.to_json
|
|
112
146
|
end
|
|
113
147
|
|
|
114
|
-
def
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
148
|
+
def toml_string(value)
|
|
149
|
+
value.start_with?('"') ? JSON.parse(value) : value[1...-1]
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Deliberately limited to surgical URL blocks, not a TOML serializer.
|
|
153
|
+
def codex_blocks(text)
|
|
154
|
+
lines = text.lines
|
|
155
|
+
lines.each do |line|
|
|
156
|
+
next if line.lstrip.start_with?('#')
|
|
157
|
+
if line.include?('"""') || line.include?("'''")
|
|
158
|
+
raise 'codex: multiline TOML strings are unsupported by surgical URL sync/status'
|
|
159
|
+
end
|
|
160
|
+
if line.match?(/\A[ \t]*(?:mcp_servers|"mcp_servers"|'mcp_servers')[ \t]*(?:\.|=)/)
|
|
161
|
+
raise 'codex: inline or dotted MCP definitions are unsupported; use simple [mcp_servers.NAME] URL blocks'
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
headers = lines.each_index.select { |index| lines[index].lstrip.start_with?('[') }
|
|
165
|
+
blocks = {}
|
|
166
|
+
headers.each_with_index do |start, index|
|
|
167
|
+
header = lines[start]
|
|
168
|
+
next unless header.match?(/\A[ \t]*\[\[?[ \t]*(?:mcp_servers|"mcp_servers"|'mcp_servers')(?:[ \t]*[.\]])/)
|
|
169
|
+
match = header.match(/\A[ \t]*\[[ \t]*mcp_servers[ \t]*\.[ \t]*(#{TOML_KEY.source})([^\]\r\n]*)\][ \t]*(?:#[^\r\n]*)?(?:\r?\n)?\z/)
|
|
170
|
+
raise 'codex: unsupported MCP table shape; use simple [mcp_servers.NAME] URL blocks' unless match
|
|
171
|
+
begin
|
|
172
|
+
name = match[1].start_with?('"', "'") ? toml_string(match[1]) : match[1]
|
|
173
|
+
rescue JSON::ParserError
|
|
174
|
+
raise 'codex: unsupported quoted MCP table key'
|
|
175
|
+
end
|
|
176
|
+
tail = match[2].strip
|
|
177
|
+
unless tail.empty?
|
|
178
|
+
raise 'codex: unsupported MCP table key' unless tail.start_with?('.')
|
|
179
|
+
blocks[name] ||= {}
|
|
180
|
+
blocks[name][:error] = 'MCP subtables are unsupported by URL-only sync'
|
|
181
|
+
next
|
|
182
|
+
end
|
|
183
|
+
raise %(codex: duplicate MCP table for #{name}) if blocks.dig(name, :range)
|
|
184
|
+
stop = headers[index + 1] || lines.length
|
|
185
|
+
block_lines = lines[start...stop]
|
|
186
|
+
block = { range: start...stop, text: block_lines.join, error: blocks.dig(name, :error) }
|
|
187
|
+
block_lines.each_with_index do |line, offset|
|
|
188
|
+
next if offset.zero? || line.strip.empty? || line.lstrip.start_with?('#')
|
|
189
|
+
assignment = CODEX_URL_LINE.match(line)
|
|
190
|
+
if !assignment || block.key?(:url_line)
|
|
191
|
+
block[:error] ||= 'expected a URL-only block; command, authentication, duplicate URL, and extra settings are unsupported'
|
|
126
192
|
next
|
|
127
193
|
end
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
194
|
+
begin
|
|
195
|
+
url = toml_string(assignment[2])
|
|
196
|
+
uri = URI.parse(url)
|
|
197
|
+
raise URI::InvalidURIError unless uri.is_a?(URI::HTTP) && uri.host
|
|
198
|
+
block.merge!(url: url, url_line: offset)
|
|
199
|
+
rescue JSON::ParserError, URI::InvalidURIError
|
|
200
|
+
block[:error] ||= 'expected a single-line absolute HTTP or HTTPS URL'
|
|
132
201
|
end
|
|
133
|
-
lines[start...stop] = block
|
|
134
|
-
text = lines.join
|
|
135
|
-
else
|
|
136
|
-
text += "\n[mcp_servers.#{key}]\nurl = #{url.to_json}\n"
|
|
137
202
|
end
|
|
203
|
+
blocks[name] = block
|
|
138
204
|
end
|
|
205
|
+
blocks
|
|
206
|
+
end
|
|
139
207
|
|
|
208
|
+
def sync_codex(path:, servers:, store:, opts:)
|
|
209
|
+
original = File.exist?(path) ? File.read(path) : ''
|
|
210
|
+
text = original.dup
|
|
211
|
+
blocks = codex_blocks(text)
|
|
212
|
+
servers.each_key do |name|
|
|
213
|
+
error = blocks.dig(name, :error)
|
|
214
|
+
raise %(codex: cannot update #{name}: #{error}) if error
|
|
215
|
+
end
|
|
216
|
+
ownership = store.sync_state(path)
|
|
217
|
+
edits, removed = [], []
|
|
140
218
|
if opts.prune
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
ranges << (i...j)
|
|
150
|
-
puts " pruned codex server: #{name}"
|
|
151
|
-
end
|
|
152
|
-
i = j
|
|
153
|
-
else
|
|
154
|
-
i += 1
|
|
219
|
+
(ownership.keys - servers.keys).each do |name|
|
|
220
|
+
block = blocks[name]
|
|
221
|
+
if !block
|
|
222
|
+
ownership.delete(name)
|
|
223
|
+
elsif !block[:error] && block[:range] && export_fingerprint(block[:text]) == ownership[name]
|
|
224
|
+
edits << [block[:range], []]
|
|
225
|
+
ownership.delete(name)
|
|
226
|
+
removed << name
|
|
155
227
|
end
|
|
156
228
|
end
|
|
157
|
-
ranges.reverse_each { |r| lines[r] = [] }
|
|
158
|
-
text = lines.join
|
|
159
229
|
end
|
|
160
|
-
|
|
230
|
+
servers.each do |name, entry|
|
|
231
|
+
block = blocks[name]
|
|
232
|
+
next unless block
|
|
233
|
+
updated = block.fetch(:text).lines
|
|
234
|
+
if block.key?(:url_line)
|
|
235
|
+
offset = block.fetch(:url_line)
|
|
236
|
+
assignment = CODEX_URL_LINE.match(updated[offset])
|
|
237
|
+
updated[offset] = assignment[1] + entry.fetch('url').to_json + assignment[3]
|
|
238
|
+
else
|
|
239
|
+
updated[0] += "\n" unless updated[0].end_with?("\n")
|
|
240
|
+
updated.insert(1, "url = #{entry.fetch('url').to_json}\n")
|
|
241
|
+
end
|
|
242
|
+
edits << [block.fetch(:range), updated]
|
|
243
|
+
end
|
|
244
|
+
lines = text.lines
|
|
245
|
+
edits.sort_by { |range, _| range.begin }.reverse_each { |range, replacement| lines[range] = replacement }
|
|
246
|
+
text = lines.join
|
|
247
|
+
servers.each do |name, entry|
|
|
248
|
+
next if blocks.key?(name)
|
|
249
|
+
text += "\n" unless text.empty? || text.end_with?("\n")
|
|
250
|
+
text += "[mcp_servers.#{toml_key(name)}]\nurl = #{entry.fetch('url').to_json}\n"
|
|
251
|
+
end
|
|
252
|
+
updated_blocks = codex_blocks(text)
|
|
253
|
+
servers.each_key { |name| ownership[name] = export_fingerprint(updated_blocks.fetch(name).fetch(:text)) }
|
|
161
254
|
if opts.dry_run
|
|
162
255
|
puts "[dry-run] #{path}"
|
|
163
256
|
puts text
|
|
164
|
-
elsif !File.exist?(path) || text != File.read(path)
|
|
165
|
-
backup(path)
|
|
166
|
-
File.write(path, text)
|
|
167
|
-
puts "wrote #{path}"
|
|
168
257
|
else
|
|
169
|
-
|
|
258
|
+
if !File.exist?(path) || text != original
|
|
259
|
+
write_config(path, text)
|
|
260
|
+
puts "wrote #{path}"
|
|
261
|
+
else
|
|
262
|
+
puts "unchanged #{path}"
|
|
263
|
+
end
|
|
264
|
+
store.save_sync_state(path, ownership)
|
|
170
265
|
end
|
|
266
|
+
puts " pruned: #{removed.join(', ')}" unless removed.empty?
|
|
171
267
|
end
|
|
172
268
|
|
|
173
|
-
def
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
3. Re-read the file to verify the result parses and contains the new entries.
|
|
190
|
-
4. Report the file path, the format used, and what changed. Do not modify
|
|
191
|
-
anything else on this machine.
|
|
192
|
-
PROMPT
|
|
193
|
-
|
|
194
|
-
if opts.dry_run
|
|
195
|
-
puts '[dry-run] would invoke omp with prompt:'
|
|
196
|
-
puts prompt
|
|
197
|
-
return
|
|
269
|
+
def config_entries(agent, path, names: [])
|
|
270
|
+
return {} unless File.exist?(path)
|
|
271
|
+
if agent == 'codex'
|
|
272
|
+
codex_blocks(File.read(path)).to_h do |name, block|
|
|
273
|
+
raise %(codex: cannot compare #{name}: #{block[:error]}) if block[:error] && names.include?(name)
|
|
274
|
+
[name, block[:error] ? nil : { 'url' => block[:url] }]
|
|
275
|
+
end
|
|
276
|
+
else
|
|
277
|
+
_, keys, = AGENT_TARGETS.fetch(agent).call(Dir.home)
|
|
278
|
+
data = JSON.parse(File.read(path))
|
|
279
|
+
container = keys.inject(data) do |parent, key|
|
|
280
|
+
raise %(expected configuration object in #{path}) unless parent.is_a?(Hash)
|
|
281
|
+
parent.fetch(key, {})
|
|
282
|
+
end
|
|
283
|
+
raise %(expected server container object in #{path}) unless container.is_a?(Hash)
|
|
284
|
+
container
|
|
198
285
|
end
|
|
286
|
+
end
|
|
199
287
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
288
|
+
def sync_options(args, status: false)
|
|
289
|
+
parser = Hiiro::Options.setup do
|
|
290
|
+
option(:profile, desc: 'OMP profile name')
|
|
291
|
+
flag(:project, short: 'P', desc: 'use project-level config in cwd')
|
|
292
|
+
if status
|
|
293
|
+
flag(:json, desc: 'machine-readable definition comparison')
|
|
294
|
+
else
|
|
295
|
+
flag(:prune, short: 'p', desc: 'remove unchanged stale entries previously exported here')
|
|
296
|
+
flag(:dry_run, long: 'dry-run', short: 'n', desc: 'print proposed config without writing')
|
|
297
|
+
end
|
|
203
298
|
end
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
299
|
+
tokens = args.dup
|
|
300
|
+
until tokens.empty?
|
|
301
|
+
argument = tokens.shift
|
|
302
|
+
break if argument == '--'
|
|
303
|
+
next unless argument.start_with?('-')
|
|
304
|
+
if argument.start_with?('--')
|
|
305
|
+
token, value = argument.split('=', 2)
|
|
306
|
+
definition = parser.option_set.definition_for(token)
|
|
307
|
+
raise %(unknown option #{token}; use --help) unless definition
|
|
308
|
+
if definition.flag?
|
|
309
|
+
raise %(#{token} does not take a value) if value
|
|
310
|
+
elsif value.nil?
|
|
311
|
+
raise %(#{token} requires a value) if tokens.empty? || tokens.first.start_with?('-')
|
|
312
|
+
tokens.shift
|
|
313
|
+
elsif value.empty?
|
|
314
|
+
raise %(#{token} requires a value)
|
|
315
|
+
end
|
|
316
|
+
else
|
|
317
|
+
flags = argument.delete_prefix('-').chars.map { |char| parser.option_set.definition_for("-#{char}") }
|
|
318
|
+
raise %(unknown option #{argument}; use --help) if flags.empty? || flags.any? { |flag| !flag || !flag.flag? }
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
parser.parse(args)
|
|
207
322
|
end
|
|
208
323
|
|
|
209
|
-
def
|
|
210
|
-
|
|
211
|
-
if
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
324
|
+
def target_config(agent, opts)
|
|
325
|
+
raise %(unknown agent #{agent}; choose #{AGENT_TARGETS.keys.join(', ')}) unless AGENT_TARGETS.key?(agent)
|
|
326
|
+
if opts[:profile]
|
|
327
|
+
raise '--profile is only supported for omp' unless agent == 'omp'
|
|
328
|
+
raise 'select --profile or --project, not both' if opts.project
|
|
329
|
+
unless opts[:profile].match?(/\A[A-Za-z0-9_.-]+\z/) && !%w[. ..].include?(opts[:profile])
|
|
330
|
+
raise '--profile must be a profile name, not a path'
|
|
216
331
|
end
|
|
217
|
-
entries
|
|
218
|
-
else
|
|
219
|
-
_, keys, = AGENT_TARGETS[agent].call(Dir.home)
|
|
220
|
-
data = JSON.parse(File.read(path))
|
|
221
|
-
container = (keys || ['mcpServers']).inject(data) { |h, k| h.is_a?(Hash) ? h[k] : nil } || {}
|
|
222
|
-
container.transform_values { |v| v.is_a?(Hash) ? (v['url'] || v['httpUrl'] || v['serverUrl']) : nil }.compact
|
|
223
332
|
end
|
|
224
|
-
|
|
225
|
-
|
|
333
|
+
path, keys, builder = AGENT_TARGETS.fetch(agent).call(Dir.home)
|
|
334
|
+
if opts.project || agent == 'vscode'
|
|
335
|
+
relative = { 'omp' => '.omp/mcp.json', 'codex' => '.codex/config.toml', 'vscode' => '.vscode/mcp.json' }[agent] || PROJECT_PATHS.fetch(agent)
|
|
336
|
+
path = File.join(Dir.pwd, relative)
|
|
337
|
+
elsif opts[:profile]
|
|
338
|
+
path = File.join(Dir.home, '.omp', 'profiles', opts[:profile], 'agent', 'mcp.json')
|
|
339
|
+
end
|
|
340
|
+
[File.expand_path(path), keys, builder]
|
|
226
341
|
end
|
|
227
342
|
|
|
228
343
|
def print_table(rows)
|
|
229
344
|
return puts '(no results)' if rows.empty?
|
|
230
345
|
keys = rows.first.keys
|
|
231
|
-
widths = keys.map { |
|
|
232
|
-
header = keys.each_with_index.map { |
|
|
346
|
+
widths = keys.map { |key| [key.to_s.length, rows.map { |row| row[key].to_s.length }.max].max.clamp(0, 60) }
|
|
347
|
+
header = keys.each_with_index.map { |key, index| key.to_s.ljust(widths[index]) }.join(' ')
|
|
233
348
|
puts header
|
|
234
349
|
puts '-' * header.length
|
|
235
350
|
rows.each do |row|
|
|
236
|
-
puts keys.each_with_index.map { |
|
|
351
|
+
puts keys.each_with_index.map { |key, index| row[key].to_s.ljust(widths[index]) }.join(' ')
|
|
237
352
|
end
|
|
238
353
|
puts "(#{rows.length} rows)"
|
|
239
354
|
end
|
|
240
355
|
|
|
241
356
|
Hiiro.run(*ARGV) do
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
rows = fetch_registry(opts[:endpoint]).map do |s|
|
|
245
|
-
{
|
|
246
|
-
name: s[:display_name],
|
|
247
|
-
url: s[:short_url],
|
|
248
|
-
auth: s[:auth_status],
|
|
249
|
-
last_used: s[:last_used_at].to_s.sub('T', ' ').sub(/Z.*/, ''),
|
|
250
|
-
}
|
|
251
|
-
end
|
|
252
|
-
print_table(rows)
|
|
253
|
-
end
|
|
254
|
-
|
|
255
|
-
add_subcmd(:ui) do |*args|
|
|
256
|
-
opts = Hiiro::Options.parse(args) { option(:endpoint, default: DEFAULT_ENDPOINT, desc: 'registry endpoint') }
|
|
257
|
-
base = "http://#{URI.parse(opts[:endpoint]).authority}"
|
|
258
|
-
system('open', base)
|
|
357
|
+
%w[ls list info add import remove configured discover saved tools tool call guide test url].each do |command|
|
|
358
|
+
add_subcmd(command.to_sym) { |*args| MCPCommands.run(command, args) }
|
|
259
359
|
end
|
|
260
360
|
|
|
261
361
|
add_subcmd(:sync) do |*args|
|
|
262
|
-
opts =
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
flag(:dry_run, short: 'n', desc: 'print changes without writing')
|
|
362
|
+
opts = sync_options(args)
|
|
363
|
+
next puts(opts.help_text) if opts.help?
|
|
364
|
+
if opts.args.empty?
|
|
365
|
+
puts 'Usage: h mcp sync AGENT [--prune] [--dry-run] [--project | --profile NAME]'
|
|
366
|
+
next puts("agents: #{AGENT_TARGETS.keys.join(', ')}")
|
|
268
367
|
end
|
|
368
|
+
raise 'sync accepts one AGENT' unless opts.args.length == 1
|
|
269
369
|
agent = opts.args.first
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
servers = registry_servers(opts[:endpoint])
|
|
277
|
-
authority = URI.parse(opts[:endpoint]).authority
|
|
278
|
-
home = Dir.home
|
|
279
|
-
|
|
280
|
-
case agent
|
|
281
|
-
when 'omp'
|
|
282
|
-
path =
|
|
283
|
-
if opts.project then File.join(Dir.pwd, '.omp', 'mcp.json')
|
|
284
|
-
elsif opts.profile then File.join(home, '.omp', 'profiles', opts.profile, 'agent', 'mcp.json')
|
|
285
|
-
else File.join(home, '.omp', 'agent', 'mcp.json')
|
|
286
|
-
end
|
|
287
|
-
sync_json(path: path, container_keys: ['mcpServers'], servers: servers,
|
|
288
|
-
authority: authority, opts: opts, schema: OMP_SCHEMA, &HTTP_ENTRY)
|
|
289
|
-
when 'codex'
|
|
290
|
-
path = opts.project ? File.join(Dir.pwd, '.codex', 'config.toml')
|
|
291
|
-
: File.join(home, '.codex', 'config.toml')
|
|
292
|
-
sync_codex(path: path, servers: servers, authority: authority, opts: opts)
|
|
293
|
-
when 'vscode'
|
|
294
|
-
sync_json(path: File.join(Dir.pwd, '.vscode', 'mcp.json'), container_keys: ['servers'],
|
|
295
|
-
servers: servers, authority: authority, opts: opts, &HTTP_ENTRY)
|
|
296
|
-
when *AGENT_TARGETS.keys
|
|
297
|
-
path, keys, builder = AGENT_TARGETS[agent].call(home)
|
|
298
|
-
if opts.project
|
|
299
|
-
rel = PROJECT_PATHS[agent] or abort "no project-level config known for #{agent}"
|
|
300
|
-
path = File.join(Dir.pwd, rel)
|
|
301
|
-
end
|
|
302
|
-
sync_json(path: path, container_keys: keys, servers: servers,
|
|
303
|
-
authority: authority, opts: opts, &builder)
|
|
370
|
+
path, keys, builder = target_config(agent, opts)
|
|
371
|
+
catalog = MCPCatalog.new
|
|
372
|
+
entries = target_entries(agent, local_servers(catalog), builder)
|
|
373
|
+
if agent == 'codex'
|
|
374
|
+
sync_codex(path: path, servers: entries, store: catalog.store, opts: opts)
|
|
304
375
|
else
|
|
305
|
-
|
|
376
|
+
sync_json(path: path, container_keys: keys, servers: entries, store: catalog.store, opts: opts,
|
|
377
|
+
schema: agent == 'omp' ? OMP_SCHEMA : nil)
|
|
306
378
|
end
|
|
379
|
+
rescue StandardError => error
|
|
380
|
+
abort %(h mcp sync: #{MCPCommands.error_message(error)})
|
|
307
381
|
end
|
|
308
382
|
|
|
309
383
|
add_subcmd(:status) do |*args|
|
|
310
|
-
opts =
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
384
|
+
opts = sync_options(args, status: true)
|
|
385
|
+
next puts(opts.help_text) if opts.help?
|
|
386
|
+
raise 'status accepts at most one AGENT' if opts.args.length > 1
|
|
387
|
+
selected = opts.args.first
|
|
388
|
+
agents = selected ? [selected] : (opts[:profile] ? ['omp'] : AGENT_TARGETS.keys)
|
|
389
|
+
targets = agents.map { |agent| [agent, *target_config(agent, opts)] }
|
|
390
|
+
catalog = MCPCatalog.new
|
|
391
|
+
servers = local_servers(catalog)
|
|
392
|
+
rows = targets.filter_map do |agent, path, _keys, builder|
|
|
393
|
+
next unless selected || File.exist?(path)
|
|
394
|
+
expected = target_entries(agent, servers, builder)
|
|
395
|
+
entries = config_entries(agent, path, names: expected.keys)
|
|
396
|
+
ownership = catalog.store.sync_state(path)
|
|
397
|
+
{ agent: agent, config: path, missing: (expected.keys - entries.keys).sort,
|
|
398
|
+
changed: (expected.keys & entries.keys).reject { |name| entries[name] == expected[name] }.sort,
|
|
399
|
+
stale: (ownership.keys - expected.keys).select { |name| entries.key?(name) }.sort }
|
|
324
400
|
end
|
|
325
|
-
if
|
|
326
|
-
puts
|
|
401
|
+
if opts.json
|
|
402
|
+
puts JSON.pretty_generate(rows)
|
|
327
403
|
else
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
name = opts.args.first
|
|
336
|
-
abort 'usage: h mcp test <name>' unless name
|
|
337
|
-
url = registry_servers(opts[:endpoint])[name]
|
|
338
|
-
abort "no such server '#{name}' (try: h mcp list)" unless url
|
|
339
|
-
|
|
340
|
-
uri = URI.parse(url)
|
|
341
|
-
body = {
|
|
342
|
-
jsonrpc: '2.0', id: 1, method: 'initialize',
|
|
343
|
-
params: { protocolVersion: '2025-03-26', capabilities: {},
|
|
344
|
-
clientInfo: { name: 'h-mcp', version: '1.0' } },
|
|
345
|
-
}.to_json
|
|
346
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
347
|
-
http.read_timeout = 10
|
|
348
|
-
res = http.post(uri.path, body, 'Content-Type' => 'application/json',
|
|
349
|
-
'Accept' => 'application/json, text/event-stream')
|
|
350
|
-
payload = res.body[/^data: (.+)$/m, 1] || res.body
|
|
351
|
-
info = JSON.parse(payload).dig('result', 'serverInfo') || {}
|
|
352
|
-
if info.empty?
|
|
353
|
-
puts "FAIL #{name}: HTTP #{res.code}, no serverInfo in response"
|
|
354
|
-
exit 1
|
|
404
|
+
drift = rows.reject { |row| row[:missing].empty? && row[:changed].empty? && row[:stale].empty? }
|
|
405
|
+
if drift.empty?
|
|
406
|
+
puts 'all inspected agent configs are in sync with enabled local HTTP registrations'
|
|
407
|
+
else
|
|
408
|
+
print_table(drift.map { |row| row.transform_values { |value| value.is_a?(Array) ? value.join(',') : value } })
|
|
409
|
+
puts 'run: h mcp sync AGENT [--prune] with the same project/profile selection'
|
|
410
|
+
end
|
|
355
411
|
end
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
add_subcmd(:url) do |*args|
|
|
360
|
-
opts = Hiiro::Options.parse(args) { option(:endpoint, default: DEFAULT_ENDPOINT, desc: 'registry endpoint') }
|
|
361
|
-
name = opts.args.first
|
|
362
|
-
abort 'usage: h mcp url <name>' unless name
|
|
363
|
-
url = registry_servers(opts[:endpoint])[name]
|
|
364
|
-
abort "no such server '#{name}'" unless url
|
|
365
|
-
puts url
|
|
366
|
-
end
|
|
367
|
-
|
|
368
|
-
add_subcmd(:info) do |*args|
|
|
369
|
-
opts = Hiiro::Options.parse(args) { option(:endpoint, default: DEFAULT_ENDPOINT, desc: 'registry endpoint') }
|
|
370
|
-
name = opts.args.first
|
|
371
|
-
abort 'usage: h mcp info <name>' unless name
|
|
372
|
-
record = fetch_registry(opts[:endpoint]).find { |s| s[:display_name] == name }
|
|
373
|
-
abort "no such server '#{name}'" unless record
|
|
374
|
-
puts JSON.pretty_generate(JSON.parse(record.to_json))
|
|
412
|
+
rescue StandardError => error
|
|
413
|
+
abort %(h mcp status: #{MCPCommands.error_message(error)})
|
|
375
414
|
end
|
|
376
415
|
|
|
377
416
|
add_subcmd(:agents) do
|
|
378
|
-
home = Dir.home
|
|
379
417
|
rows = AGENT_TARGETS.map do |agent, target|
|
|
380
|
-
path, = target.call(home)
|
|
418
|
+
path, = target.call(Dir.home)
|
|
381
419
|
{ agent: agent, config: path || '(project-only)', exists: path && File.exist?(path) ? 'yes' : 'no' }
|
|
382
420
|
end
|
|
383
421
|
print_table(rows)
|
data/docs/README.md
CHANGED
|
@@ -68,6 +68,7 @@ Quick-reference index of all `h-*` executables and the main `h` entry point.
|
|
|
68
68
|
| Command | Description |
|
|
69
69
|
|---------|-------------|
|
|
70
70
|
| [h-bg](h-bg.md) | Run commands in background Herdr tabs with history tracking |
|
|
71
|
+
| [h-extract](../README.md#extract-files-directories-and-links) | Extract file paths, directories, and HTTP(S) links from text |
|
|
71
72
|
| [h-img](h-img.md) | Save or base64-encode images from clipboard or file |
|
|
72
73
|
| [h-save](h-save.md) | Save clipboard text or images to ~/saved |
|
|
73
74
|
| [h-misc](h-misc.md) | Miscellaneous utilities (symlink destination reporting) |
|
data/docs/h.md
CHANGED
|
@@ -48,6 +48,7 @@ These are separate `bin/h-*` executables dispatched by `h`:
|
|
|
48
48
|
| [`h config`](h-config.md) | Open config files in editor |
|
|
49
49
|
| [`h cpr`](h-cpr.md) | Shortcut to current branch's PR |
|
|
50
50
|
| [`h db`](h-db.md) | SQLite database inspection and management |
|
|
51
|
+
| [`h extract`](../README.md#extract-files-directories-and-links) | Extract file paths, directories, and HTTP(S) links from text |
|
|
51
52
|
| [`h img`](h-img.md) | Image clipboard utilities |
|
|
52
53
|
| [`h link`](h-link.md) | URL bookmark management |
|
|
53
54
|
| [`h misc`](h-misc.md) | Miscellaneous utilities |
|
data/lib/hiiro/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hiiro
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.389
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Joshua Toyota
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-07 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: pry
|
|
@@ -169,6 +169,7 @@ files:
|
|
|
169
169
|
- bin/h-dsstore
|
|
170
170
|
- bin/h-dsstore-read
|
|
171
171
|
- bin/h-env
|
|
172
|
+
- bin/h-extract
|
|
172
173
|
- bin/h-git
|
|
173
174
|
- bin/h-herdr
|
|
174
175
|
- bin/h-home
|