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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 75bc072edb723fca8202393111317406374a79d033efd727f5f2e6de708a1eca
4
- data.tar.gz: 220302e41947fce7f174a11040b363ae101c29ab0dc39ac8014302e076da36b2
3
+ metadata.gz: 7702a9aa5a7b32c1f0038e95551844d0da347fcaebeaf84ae074190839f35b56
4
+ data.tar.gz: 06e1c1ce29d001051f5b8190a36163577c75c5a38eacc6407b49c305f62ec944
5
5
  SHA512:
6
- metadata.gz: e73489ef10468e4d7d95c1eb122ca5554c25e0ed2ea039497e1618a4c82130acdec2aa1458cbed31a55ad3be95d3bc7472bee53564aa9289df57e9d2902a87f2
7
- data.tar.gz: 8b76fd1f7b729ad8773a2fbffad8b6ab6eceaaa1a4cd7895517fe8ea28fed98c8a590e61b43d2ece451af86a29c316bc1f1c09d0580dfe2702758f23e6e20a9b
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
- # h mcp — manage MCP servers from the local registry (http://localhost:15245).
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] }, # project-only
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 registry_servers(endpoint)
59
- fetch_registry(endpoint).each_with_object({}) do |s, h|
60
- name = s[:display_name].to_s.strip
61
- url = s[:short_url].to_s.strip
62
- h[name] = url unless name.empty? || url.empty?
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 registry_url?(value, authority)
67
- return false unless value.is_a?(Hash)
68
- url = value['url'] || value['httpUrl'] || value['serverUrl']
69
- url.is_a?(String) && url.include?(authority)
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
- FileUtils.cp(path, "#{path}.bak-#{Time.now.strftime('%Y%m%d%H%M%S')}") if File.exist?(path)
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:, authority:, opts:, schema: nil, &entry_for)
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) { |h, k| h[k] ||= {} }
80
-
81
- before = container.keys.sort
82
- servers.each { |name, url| container[name] = entry_for.call(url) }
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
- container.each_key do |name|
86
- next if servers.key?(name)
87
- next unless registry_url?(container[name], authority)
88
- removed << name
89
- container.delete(name)
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
- after = container.keys.sort
93
-
94
- changed = (before != after) || servers.any? { |n, u| container[n] != entry_for.call(u) }
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(container)
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
- puts "unchanged #{path}"
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: #{after.join(', ')}"
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 =~ /\A[A-Za-z0-9_-]+\z/ ? name : "\"#{name.gsub('"', '\"')}\""
145
+ name.match?(/\A[A-Za-z0-9_-]+\z/) ? name : name.to_json
112
146
  end
113
147
 
114
- def sync_codex(path:, servers:, authority:, opts:)
115
- text = File.exist?(path) ? File.read(path) : ''
116
- servers.each do |name, url|
117
- key = toml_key(name)
118
- header = /^\[mcp_servers\.#{Regexp.escape(key)}\]\s*$/
119
- lines = text.lines
120
- start = lines.index { |l| l =~ header }
121
- if start
122
- stop = ((start + 1)...lines.length).find { |i| lines[i] =~ /^\[/ } || lines.length
123
- block = lines[start...stop]
124
- if block.any? { |l| l =~ /^\s*command\s*=/ }
125
- warn "codex: '#{name}' is command-based; leaving untouched"
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
- if (i = block.index { |l| l =~ /^\s*url\s*=/ })
129
- block[i] = "url = #{url.to_json}\n"
130
- else
131
- block.insert(1, "url = #{url.to_json}\n")
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
- lines = text.lines
142
- ranges = []
143
- i = 0
144
- while i < lines.length
145
- if lines[i] =~ /^\[mcp_servers\.([^\]]+)\]\s*$/
146
- name = Regexp.last_match(1).delete('"')
147
- j = ((i + 1)...lines.length).find { |k| lines[k] =~ /^\[/ } || lines.length
148
- if !servers.key?(name) && lines[i...j].any? { |l| l =~ /^\s*url\s*=.*#{Regexp.escape(authority)}/ }
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
- puts "unchanged #{path}"
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 fallback_omp(agent, servers, opts)
174
- payload = JSON.pretty_generate(servers.map { |n, u| { name: n, url: u } })
175
- prompt = <<~PROMPT
176
- Configure MCP (Model Context Protocol) servers for the agent/CLI tool called "#{agent}" on this machine.
177
-
178
- Here is the server list JSON from the local MCP registry. Each entry is a remote
179
- streamable-HTTP MCP endpoint reachable locally with no auth headers:
180
-
181
- #{payload}
182
-
183
- Steps:
184
- 1. Determine where "#{agent}" keeps its MCP server configuration (config file path
185
- and exact entry format). Search the web / official docs if you are unsure.
186
- 2. Merge every server above into that config under its exact name, as a remote
187
- HTTP MCP entry pointing at its url. Preserve all unrelated existing entries
188
- and settings in the file. Back up the file before modifying it.
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
- omp = ENV.fetch('OMP_BIN', 'omp')
201
- unless system('which', omp, out: File::NULL, err: File::NULL)
202
- abort "unknown agent '#{agent}' and no omp binary found to research it"
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
- puts "unknown agent '#{agent}' — handing off to omp to research and apply..."
205
- system(omp, '-p', '--yolo', '--no-session', '--max-time', '10m', prompt) or
206
- abort 'omp fallback failed'
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 config_entries(agent, path)
210
- return {} unless path && File.exist?(path)
211
- if agent == 'codex'
212
- entries = {}
213
- File.read(path).scan(/^\[mcp_servers\.(?:\"([^\"]+)\"|([^\]]+))\]\s*\n(.*?)(?=^\[|\z)/m) do |quoted, bare, body|
214
- url = body[/^\s*url\s*=\s*"([^"]+)"/, 1]
215
- entries[quoted || bare] = url if url
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
- rescue JSON::ParserError
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 { |k| [k.to_s.length, rows.map { |r| r[k].to_s.length }.max].max.clamp(0, 60) }
232
- header = keys.each_with_index.map { |k, i| k.to_s.ljust(widths[i]) }.join(' ')
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 { |k, i| row[k].to_s.ljust(widths[i]) }.join(' ')
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
- add_subcmd(:ls, :list) do |*args|
243
- opts = Hiiro::Options.parse(args) { option(:endpoint, default: DEFAULT_ENDPOINT, desc: 'registry endpoint') }
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 = Hiiro::Options.parse(args) do
263
- option(:endpoint, default: DEFAULT_ENDPOINT, desc: 'registry endpoint')
264
- option(:profile, desc: 'omp profile name')
265
- flag(:project, short: 'P', desc: 'write project-level config in cwd')
266
- flag(:prune, short: 'p', desc: 'remove registry-sourced entries gone upstream')
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
- unless agent
271
- puts "Usage: h mcp sync <agent> [--prune] [--dry-run] [--project] [--profile NAME] [--endpoint URL]"
272
- puts "agents: #{AGENT_TARGETS.keys.join(', ')} (anything else: researched + applied via omp)"
273
- next
274
- end
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
- fallback_omp(agent, servers, opts)
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 = Hiiro::Options.parse(args) { option(:endpoint, default: DEFAULT_ENDPOINT, desc: 'registry endpoint') }
311
- servers = registry_servers(opts[:endpoint])
312
- authority = URI.parse(opts[:endpoint]).authority
313
- home = Dir.home
314
- rows = []
315
- AGENT_TARGETS.each do |agent, target|
316
- path, = target.call(home)
317
- next unless path
318
- next unless File.exist?(path)
319
- entries = config_entries(agent, path)
320
- missing = servers.keys - entries.keys
321
- stale = entries.keys.reject { |n| servers.key?(n) || !entries[n].to_s.include?(authority) }
322
- next if missing.empty? && stale.empty?
323
- rows << { agent: agent, missing_upstream: missing.join(','), stale_in_config: stale.join(',') }
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 rows.empty?
326
- puts 'all existing agent configs are in sync with the registry'
401
+ if opts.json
402
+ puts JSON.pretty_generate(rows)
327
403
  else
328
- print_table(rows)
329
- puts "run: h mcp sync <agent> --prune"
330
- end
331
- end
332
-
333
- add_subcmd(:test) do |*args|
334
- opts = Hiiro::Options.parse(args) { option(:endpoint, default: DEFAULT_ENDPOINT, desc: 'registry endpoint') }
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
- puts "OK #{name}: #{info['name']} #{info['version']}"
357
- end
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
@@ -1,4 +1,4 @@
1
1
  class Hiiro
2
- VERSION = "0.1.388"
2
+ VERSION = "0.1.389"
3
3
  SUPPORTED_RUBY_VERSION = ">= 3.2.0"
4
4
  end
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.388
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-02 00:00:00.000000000 Z
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