tina4ruby 3.11.14 → 3.11.16

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: 0306b1bed1468e9d48729f576f4494f01f0ba42bd0868f137c56a533d4d3a987
4
- data.tar.gz: 500e0e589914771bdc96b5b901dc0b9948ecf901df072b0ed845fd31efe7ff34
3
+ metadata.gz: a33bdb237a1acedfe4903a9145ded69e405e9e63403c9a8c2e548795060d2740
4
+ data.tar.gz: bf61f8a4a84be8f910c9767a23437ec8fbc4820fb3eb05da3ef73657b16f2585
5
5
  SHA512:
6
- metadata.gz: 225d391063a44527dc0df900ee7cce8fa20151be48a6a786fd41e2f6130253cff8899e1939a1a067e2d3f6879463f8b5cd7c888a85af29baaac85c8608a1607e
7
- data.tar.gz: 0ece15acd6ebdeef69e90f67e10b250a3c1e05dee513ea3fad2f5794df93abc10141471362e59040bcff03f3aeeb2986dc4e7e6e1e1670a7721e26af82e0d902
6
+ metadata.gz: 1c43c2556e8b8b742a1cb1cb52e26b011b1c64b56cdfb636fbfdf569789f0c7fa0d25094a201cda3d301fc9982832acfe60c01c4221b033e58a41dc6b06e7eea
7
+ data.tar.gz: 8b38f262f9a5e41f3635000b22668200127c20cfe864bc18b316758ebbefeb58c4a4022961e929b0213a3a0e1657becf2348cdbcecf79c37f3f9561f7fa3bbdc
@@ -5,6 +5,8 @@ require "digest"
5
5
  require "tmpdir"
6
6
  require "net/http"
7
7
  require "uri"
8
+ require "fileutils"
9
+ require "shellwords"
8
10
  require_relative "metrics"
9
11
 
10
12
  module Tina4
@@ -494,6 +496,56 @@ module Tina4
494
496
  when ["GET", "/__dev/api/metrics/file"]
495
497
  file_path = (query_param(env, "path") || "").to_s
496
498
  json_response(Tina4::Metrics.file_detail(file_path))
499
+ when ["GET", "/__dev/api/thoughts"]
500
+ json_response(thoughts_payload)
501
+ when ["POST", "/__dev/api/supervise/create"]
502
+ body = read_json_body(env) || {}
503
+ json_response(proxy_supervisor("/supervise/create", method: "POST", body: body))
504
+ when ["GET", "/__dev/api/supervise/sessions"]
505
+ json_response(proxy_supervisor("/supervise/sessions", method: "GET", query: env["QUERY_STRING"]))
506
+ when ["GET", "/__dev/api/supervise/diff"]
507
+ json_response(proxy_supervisor("/supervise/diff", method: "GET", query: env["QUERY_STRING"]))
508
+ when ["POST", "/__dev/api/supervise/commit"]
509
+ body = read_json_body(env) || {}
510
+ json_response(proxy_supervisor("/supervise/commit", method: "POST", body: body))
511
+ when ["POST", "/__dev/api/supervise/cancel"]
512
+ body = read_json_body(env) || {}
513
+ json_response(proxy_supervisor("/supervise/cancel", method: "POST", body: body))
514
+ when ["POST", "/__dev/api/execute"]
515
+ body = read_json_body(env) || {}
516
+ execute_proxy(body)
517
+ when ["GET", "/__dev/api/files"]
518
+ json_response(files_list(env))
519
+ when ["GET", "/__dev/api/file"]
520
+ json_response(file_read_payload(query_param(env, "path")))
521
+ when ["GET", "/__dev/api/file/raw"]
522
+ file_raw_response(query_param(env, "path"))
523
+ when ["POST", "/__dev/api/file/save"]
524
+ body = read_json_body(env) || {}
525
+ json_response(file_save(body))
526
+ when ["POST", "/__dev/api/file/rename"]
527
+ body = read_json_body(env) || {}
528
+ json_response(file_rename(body))
529
+ when ["POST", "/__dev/api/file/delete"]
530
+ body = read_json_body(env) || {}
531
+ json_response(file_delete(body))
532
+ when ["GET", "/__dev/api/deps/search"]
533
+ json_response(deps_search(query_param(env, "q") || query_param(env, "query") || ""))
534
+ when ["POST", "/__dev/api/deps/install"]
535
+ body = read_json_body(env) || {}
536
+ json_response(deps_install(body))
537
+ when ["GET", "/__dev/api/git/status"]
538
+ json_response(git_status_payload)
539
+ when ["GET", "/__dev/api/mcp/tools"]
540
+ json_response(mcp_tools_list)
541
+ when ["POST", "/__dev/api/mcp/call"]
542
+ body = read_json_body(env) || {}
543
+ json_response(mcp_tool_call(body))
544
+ when ["GET", "/__dev/api/scaffold"]
545
+ json_response(scaffold_templates)
546
+ when ["POST", "/__dev/api/scaffold/run"]
547
+ body = read_json_body(env) || {}
548
+ json_response(scaffold_run(body))
497
549
  when ["GET", "/__dev/api/graphql/schema"]
498
550
  begin
499
551
  gql = Tina4::GraphQL.new
@@ -930,6 +982,308 @@ module Tina4
930
982
 
931
983
  { deployed: name, files: copied }
932
984
  end
985
+
986
+ # ── New dev-admin surface area (parity with Python/PHP) ────
987
+
988
+ def supervisor_base
989
+ base = ENV["TINA4_SUPERVISOR_URL"].to_s.strip
990
+ return base unless base.empty?
991
+ port = (ENV["TINA4_PORT"] || ENV["PORT"] || "7147").to_i + 2000
992
+ "http://127.0.0.1:#{port}"
993
+ end
994
+
995
+ def thoughts_payload
996
+ base = supervisor_base
997
+ begin
998
+ uri = URI.parse("#{base}/thoughts")
999
+ req = Net::HTTP::Get.new(uri)
1000
+ resp = Net::HTTP.start(uri.host, uri.port, open_timeout: 2, read_timeout: 5) { |h| h.request(req) }
1001
+ return JSON.parse(resp.body) if resp.is_a?(Net::HTTPSuccess)
1002
+ { thoughts: [], error: "Supervisor returned #{resp.code}" }
1003
+ rescue StandardError => e
1004
+ { thoughts: [], error: e.message }
1005
+ end
1006
+ end
1007
+
1008
+ def proxy_supervisor(path, method: "GET", body: nil, query: nil)
1009
+ base = supervisor_base
1010
+ url = "#{base}#{path}"
1011
+ url += "?#{query}" if query && !query.empty?
1012
+ begin
1013
+ uri = URI.parse(url)
1014
+ req = case method.upcase
1015
+ when "POST"
1016
+ r = Net::HTTP::Post.new(uri)
1017
+ r["Content-Type"] = "application/json"
1018
+ r.body = JSON.generate(body || {})
1019
+ r
1020
+ else
1021
+ Net::HTTP::Get.new(uri)
1022
+ end
1023
+ resp = Net::HTTP.start(uri.host, uri.port, open_timeout: 2, read_timeout: 30) { |h| h.request(req) }
1024
+ begin
1025
+ JSON.parse(resp.body)
1026
+ rescue JSON::ParserError
1027
+ { body: resp.body, status: resp.code.to_i }
1028
+ end
1029
+ rescue StandardError => e
1030
+ { error: e.message, supervisor: base }
1031
+ end
1032
+ end
1033
+
1034
+ def execute_proxy(body)
1035
+ # Proxy POST /execute to the supervisor at framework_port + 2000.
1036
+ # Pass through the response stream as-is (SSE or JSON).
1037
+ base = supervisor_base
1038
+ begin
1039
+ uri = URI.parse("#{base}/execute")
1040
+ req = Net::HTTP::Post.new(uri)
1041
+ req["Content-Type"] = "application/json"
1042
+ req["Accept"] = "text/event-stream"
1043
+ req.body = JSON.generate(body || {})
1044
+ http = Net::HTTP.new(uri.host, uri.port)
1045
+ http.open_timeout = 2
1046
+ http.read_timeout = 300
1047
+ resp = http.request(req)
1048
+ ct = resp["content-type"] || "application/json; charset=utf-8"
1049
+ [resp.code.to_i, { "content-type" => ct }, [resp.body.to_s]]
1050
+ rescue StandardError => e
1051
+ body_str = JSON.generate({ error: e.message, supervisor: base })
1052
+ [502, { "content-type" => "application/json; charset=utf-8" }, [body_str]]
1053
+ end
1054
+ end
1055
+
1056
+ def safe_project_path(rel_path)
1057
+ root = File.expand_path(Dir.pwd)
1058
+ resolved = File.expand_path(rel_path.to_s, root)
1059
+ raise ArgumentError, "path escapes project directory" unless resolved.start_with?(root)
1060
+ resolved
1061
+ end
1062
+
1063
+ def files_list(env)
1064
+ rel = query_param(env, "path") || "."
1065
+ begin
1066
+ target = safe_project_path(rel)
1067
+ return { error: "Not found" } unless File.exist?(target)
1068
+ return { error: "Not a directory" } unless File.directory?(target)
1069
+ entries = Dir.children(target).sort.map do |name|
1070
+ full = File.join(target, name)
1071
+ {
1072
+ name: name,
1073
+ type: File.directory?(full) ? "dir" : "file",
1074
+ size: File.file?(full) ? File.size(full) : 0
1075
+ }
1076
+ end
1077
+ { path: rel, entries: entries, count: entries.size }
1078
+ rescue => e
1079
+ { error: e.message }
1080
+ end
1081
+ end
1082
+
1083
+ def file_read_payload(rel)
1084
+ return { error: "path required" } if rel.nil? || rel.empty?
1085
+ begin
1086
+ target = safe_project_path(rel)
1087
+ return { error: "Not found" } unless File.exist?(target)
1088
+ return { error: "Not a file" } unless File.file?(target)
1089
+ content = File.read(target, encoding: "utf-8", invalid: :replace, undef: :replace)
1090
+ { path: rel, content: content, bytes: File.size(target) }
1091
+ rescue => e
1092
+ { error: e.message }
1093
+ end
1094
+ end
1095
+
1096
+ def file_raw_response(rel)
1097
+ return json_response({ error: "path required" }) if rel.nil? || rel.empty?
1098
+ begin
1099
+ target = safe_project_path(rel)
1100
+ return json_response({ error: "Not found" }) unless File.file?(target)
1101
+ content = File.binread(target)
1102
+ ct = case File.extname(target).downcase
1103
+ when ".css" then "text/css"
1104
+ when ".js" then "application/javascript"
1105
+ when ".json" then "application/json"
1106
+ when ".html", ".htm" then "text/html"
1107
+ when ".png" then "image/png"
1108
+ when ".jpg", ".jpeg" then "image/jpeg"
1109
+ when ".gif" then "image/gif"
1110
+ when ".svg" then "image/svg+xml"
1111
+ else "text/plain; charset=utf-8"
1112
+ end
1113
+ [200, { "content-type" => ct }, [content]]
1114
+ rescue => e
1115
+ json_response({ error: e.message })
1116
+ end
1117
+ end
1118
+
1119
+ def file_save(body)
1120
+ rel = body["path"].to_s
1121
+ content = body["content"].to_s
1122
+ return { error: "path required" } if rel.empty?
1123
+ begin
1124
+ target = safe_project_path(rel)
1125
+ existed = File.exist?(target)
1126
+ FileUtils.mkdir_p(File.dirname(target))
1127
+ File.write(target, content, encoding: "utf-8")
1128
+ Tina4::Plan.record_action(existed ? "patched" : "created", rel) if defined?(Tina4::Plan)
1129
+ { saved: rel, bytes: content.bytesize }
1130
+ rescue => e
1131
+ { error: e.message }
1132
+ end
1133
+ end
1134
+
1135
+ def file_rename(body)
1136
+ from = body["from"].to_s
1137
+ to = body["to"].to_s
1138
+ return { error: "from/to required" } if from.empty? || to.empty?
1139
+ begin
1140
+ src = safe_project_path(from)
1141
+ dst = safe_project_path(to)
1142
+ return { error: "Source not found" } unless File.exist?(src)
1143
+ FileUtils.mkdir_p(File.dirname(dst))
1144
+ File.rename(src, dst)
1145
+ { renamed: { from: from, to: to } }
1146
+ rescue => e
1147
+ { error: e.message }
1148
+ end
1149
+ end
1150
+
1151
+ def file_delete(body)
1152
+ rel = body["path"].to_s
1153
+ return { error: "path required" } if rel.empty?
1154
+ begin
1155
+ target = safe_project_path(rel)
1156
+ return { error: "Not found" } unless File.exist?(target)
1157
+ if File.directory?(target)
1158
+ FileUtils.rm_rf(target)
1159
+ else
1160
+ File.delete(target)
1161
+ end
1162
+ { deleted: rel }
1163
+ rescue => e
1164
+ { error: e.message }
1165
+ end
1166
+ end
1167
+
1168
+ def deps_search(query)
1169
+ return { results: [], count: 0, error: "query required" } if query.to_s.strip.empty?
1170
+ begin
1171
+ uri = URI.parse("https://rubygems.org/api/v1/search.json?query=#{URI.encode_www_form_component(query)}")
1172
+ http = Net::HTTP.new(uri.host, uri.port)
1173
+ http.use_ssl = true
1174
+ http.open_timeout = 5
1175
+ http.read_timeout = 8
1176
+ resp = http.request(Net::HTTP::Get.new(uri))
1177
+ if resp.is_a?(Net::HTTPSuccess)
1178
+ gems = JSON.parse(resp.body)
1179
+ results = gems.first(20).map do |g|
1180
+ { name: g["name"], version: g["version"], info: g["info"].to_s[0, 200] }
1181
+ end
1182
+ { results: results, count: results.size }
1183
+ else
1184
+ { results: [], count: 0, error: "rubygems returned #{resp.code}" }
1185
+ end
1186
+ rescue => e
1187
+ { results: [], count: 0, error: e.message }
1188
+ end
1189
+ end
1190
+
1191
+ def deps_install(body)
1192
+ name = body["name"].to_s.strip
1193
+ return { ok: false, error: "name required" } if name.empty?
1194
+ # Append to Gemfile if not present — do NOT actually bundle install.
1195
+ gemfile = File.join(Dir.pwd, "Gemfile")
1196
+ return { ok: false, error: "No Gemfile at project root" } unless File.exist?(gemfile)
1197
+ content = File.read(gemfile)
1198
+ if content.include?("gem \"#{name}\"") || content.include?("gem '#{name}'")
1199
+ return { ok: true, gem: name, note: "already in Gemfile" }
1200
+ end
1201
+ File.open(gemfile, "a") { |f| f.write("\ngem \"#{name}\"\n") }
1202
+ { ok: true, gem: name, note: "added to Gemfile; run `bundle install`" }
1203
+ end
1204
+
1205
+ def git_status_payload
1206
+ begin
1207
+ inside = `cd #{Shellwords.escape(Dir.pwd)} && git rev-parse --is-inside-work-tree 2>/dev/null`.strip
1208
+ return { error: "Not a git repository" } if inside != "true"
1209
+ branch = `cd #{Shellwords.escape(Dir.pwd)} && git branch --show-current 2>/dev/null`.strip
1210
+ status = `cd #{Shellwords.escape(Dir.pwd)} && git status --porcelain 2>/dev/null`.strip.split("\n").reject(&:empty?)
1211
+ recent = `cd #{Shellwords.escape(Dir.pwd)} && git log --oneline -5 2>/dev/null`.strip.split("\n").reject(&:empty?)
1212
+ { branch: branch, status: status, recent_commits: recent }
1213
+ rescue => e
1214
+ { error: "git unavailable: #{e.message}" }
1215
+ end
1216
+ end
1217
+
1218
+ def mcp_tools_list
1219
+ return { tools: [], count: 0 } unless defined?(Tina4::McpServer)
1220
+ server = Tina4._default_mcp_server
1221
+ list = server.tools.values.map do |t|
1222
+ { name: t["name"], description: t["description"], schema: t["inputSchema"] }
1223
+ end
1224
+ { tools: list, count: list.size }
1225
+ end
1226
+
1227
+ def mcp_tool_call(body)
1228
+ tool_name = body["name"].to_s
1229
+ args = body["arguments"] || {}
1230
+ return { error: "tool name required" } if tool_name.empty?
1231
+ return { error: "MCP not loaded" } unless defined?(Tina4::McpServer)
1232
+ server = Tina4._default_mcp_server
1233
+ payload = JSON.generate({
1234
+ "jsonrpc" => "2.0",
1235
+ "id" => 1,
1236
+ "method" => "tools/call",
1237
+ "params" => { "name" => tool_name, "arguments" => args }
1238
+ })
1239
+ raw = server.handle_message(payload)
1240
+ return {} if raw.nil? || raw.empty?
1241
+ JSON.parse(raw)
1242
+ end
1243
+
1244
+ def scaffold_templates
1245
+ # Expose built-in scaffold targets for the dev-admin UI.
1246
+ { templates: [
1247
+ { id: "route", label: "Route file", target: "src/routes" },
1248
+ { id: "model", label: "ORM model", target: "src/orm" },
1249
+ { id: "migration", label: "SQL migration", target: "migrations" },
1250
+ { id: "middleware", label: "Middleware class", target: "src/app" }
1251
+ ] }
1252
+ end
1253
+
1254
+ def scaffold_run(body)
1255
+ kind = body["kind"].to_s
1256
+ name = body["name"].to_s.strip
1257
+ return { ok: false, error: "kind + name required" } if kind.empty? || name.empty?
1258
+ project = Dir.pwd
1259
+ case kind
1260
+ when "route"
1261
+ target = File.join(project, "src", "routes", "#{name}.rb")
1262
+ FileUtils.mkdir_p(File.dirname(target))
1263
+ File.write(target, "# #{name} routes\nTina4::Router.get(\"/api/#{name}\") do |req, res|\n res.call({ hello: \"#{name}\" })\nend\n") unless File.exist?(target)
1264
+ { ok: true, created: target.sub("#{project}/", "") }
1265
+ when "model"
1266
+ target = File.join(project, "src", "orm", "#{name}.rb")
1267
+ FileUtils.mkdir_p(File.dirname(target))
1268
+ cls = name.to_s.split(/[_-]/).map(&:capitalize).join
1269
+ File.write(target, "class #{cls} < Tina4::ORM\n integer_field :id, primary_key: true, auto_increment: true\n string_field :name\nend\n") unless File.exist?(target)
1270
+ { ok: true, created: target.sub("#{project}/", "") }
1271
+ when "migration"
1272
+ ts = Time.now.strftime("%Y%m%d%H%M%S")
1273
+ target = File.join(project, "migrations", "#{ts}_#{name}.sql")
1274
+ FileUtils.mkdir_p(File.dirname(target))
1275
+ File.write(target, "-- migration: #{name}\n")
1276
+ { ok: true, created: target.sub("#{project}/", "") }
1277
+ when "middleware"
1278
+ target = File.join(project, "src", "app", "#{name}.rb")
1279
+ FileUtils.mkdir_p(File.dirname(target))
1280
+ cls = name.to_s.split(/[_-]/).map(&:capitalize).join
1281
+ File.write(target, "class #{cls}\n def self.before_check(req, res); [req, res]; end\nend\n") unless File.exist?(target)
1282
+ { ok: true, created: target.sub("#{project}/", "") }
1283
+ else
1284
+ { ok: false, error: "unknown kind: #{kind}" }
1285
+ end
1286
+ end
933
1287
  end
934
1288
  end
935
1289
  end
data/lib/tina4/frond.rb CHANGED
@@ -548,6 +548,20 @@ module Tina4
548
548
  value = eval_expr(var_name, context)
549
549
  filters.each do |fname, args|
550
550
  next if fname == "raw" || fname == "safe"
551
+
552
+ # Filter + property-access chain: `first.groupSummary` — apply
553
+ # the filter, then traverse the path on the result using a
554
+ # synthetic context so eval_expr's dotted resolution does the
555
+ # work. Parity with tina4-python + tina4-php.
556
+ real_fname, tail_path = split_filter_name_and_path(fname)
557
+ if !tail_path.empty? && @filters[real_fname]
558
+ evaluated_args = args.map { |a| eval_filter_arg(a, context) }
559
+ value = @filters[real_fname].call(value, *evaluated_args)
560
+ value = eval_expr("__frond_filter_tmp.#{tail_path}",
561
+ { "__frond_filter_tmp" => value })
562
+ next
563
+ end
564
+
551
565
  fn = @filters[fname]
552
566
  if fn
553
567
  evaluated_args = args.map { |a| eval_filter_arg(a, context) }
@@ -611,6 +625,19 @@ module Tina4
611
625
  next
612
626
  end
613
627
 
628
+ # Filter + property-access chain: `first.groupSummary` — apply
629
+ # the filter, then traverse the path on the result. Done BEFORE
630
+ # the inline fast-path so cases like `items|first.name` work
631
+ # regardless of whether `first` is an inline filter too.
632
+ real_fname, tail_path = split_filter_name_and_path(fname)
633
+ if !tail_path.empty? && @filters[real_fname]
634
+ evaluated_args = args.map { |a| eval_filter_arg(a, context) }
635
+ value = @filters[real_fname].call(value, *evaluated_args)
636
+ value = eval_expr("__frond_filter_tmp.#{tail_path}",
637
+ { "__frond_filter_tmp" => value })
638
+ next
639
+ end
640
+
614
641
  # Inline common no-arg filters for speed (skip generic dispatch)
615
642
  if args.empty? && INLINE_FILTERS.include?(fname)
616
643
  value = case fname
@@ -756,6 +783,41 @@ module Tina4
756
783
  # Filter chain parser
757
784
  # -----------------------------------------------------------------------
758
785
 
786
+ # Split "first.groupSummary" into ["first", "groupSummary"] so a
787
+ # filter segment followed by property access — `{{ x | first.name }}`
788
+ # — applies the filter then traverses the path on the result.
789
+ # Returns [fname, ""] when no structural dot is present.
790
+ #
791
+ # The split point must sit outside parens/brackets/braces and quotes
792
+ # so filter args like `round(1.5)` or `date("Y.m.d")` don't false-
793
+ # trigger. Parity with tina4-python and tina4-php.
794
+ def split_filter_name_and_path(fname)
795
+ depth = 0
796
+ in_q = nil
797
+ i = 0
798
+ n = fname.length
799
+ while i < n
800
+ ch = fname[i]
801
+ if in_q
802
+ in_q = nil if ch == in_q && (i.zero? || fname[i - 1] != "\\")
803
+ i += 1
804
+ next
805
+ end
806
+ case ch
807
+ when '"', "'"
808
+ in_q = ch
809
+ when "(", "[", "{"
810
+ depth += 1
811
+ when ")", "]", "}"
812
+ depth -= 1
813
+ when "."
814
+ return [fname[0...i], fname[(i + 1)..]] if depth.zero?
815
+ end
816
+ i += 1
817
+ end
818
+ [fname, ""]
819
+ end
820
+
759
821
  def parse_filter_chain(expr)
760
822
  cached = @filter_chain_cache[expr]
761
823
  return cached if cached
data/lib/tina4/mcp.rb CHANGED
@@ -680,6 +680,181 @@ module Tina4
680
680
  end
681
681
  }, "Seed a table with fake data")
682
682
 
683
+ # ── File patch ────────────────────────────────────
684
+ server.register_tool("file_patch", lambda { |path:, old_string:, new_string:, count: 1|
685
+ p = safe_path.call(path)
686
+ return { "error" => "File not found: #{path}" } unless File.file?(p)
687
+ original = File.read(p, encoding: "utf-8")
688
+ occurrences = original.scan(old_string).size
689
+ return { "error" => "old_string not found in #{path}" } if occurrences.zero?
690
+ if occurrences != count.to_i
691
+ return { "error" => "old_string appears #{occurrences} times, expected #{count}. Expand old_string to make it unique, or set count explicitly." }
692
+ end
693
+ updated = original.sub(old_string, new_string)
694
+ # Ruby String#sub replaces first; if count > 1, do N replacements
695
+ if count.to_i > 1
696
+ updated = original.dup
697
+ count.to_i.times { updated.sub!(old_string, new_string) }
698
+ end
699
+ File.write(p, updated, encoding: "utf-8")
700
+ rel = p.sub("#{project_root}/", "")
701
+ Tina4::Plan.record_action("patched", rel) if defined?(Tina4::Plan)
702
+ { "patched" => rel, "replacements" => count.to_i, "bytes" => updated.bytesize }
703
+ }, "Targeted edit: replace old_string with new_string in a file")
704
+
705
+ # ── Docs tools ────────────────────────────────────
706
+ framework_doc_paths = lambda do
707
+ gem_root = File.expand_path("..", File.dirname(__FILE__))
708
+ candidates = [
709
+ File.join(gem_root, "..", "CLAUDE.md"),
710
+ File.join(gem_root, "..", "AGENTS.md"),
711
+ File.join(gem_root, "..", "CONVENTIONS.md"),
712
+ File.join(gem_root, "..", "README.md"),
713
+ File.join(Dir.pwd, "CLAUDE.md")
714
+ ]
715
+ candidates.map { |p| File.expand_path(p) }.uniq.select { |p| File.file?(p) }
716
+ end
717
+
718
+ server.register_tool("docs_list", lambda {
719
+ framework_doc_paths.call.map { |p| { "name" => File.basename(p), "bytes" => File.size(p) } }
720
+ }, "List framework documentation files")
721
+
722
+ server.register_tool("docs_search", lambda { |query:, limit: 5, context_lines: 4|
723
+ return { "error" => "query must be at least 2 characters" } if query.to_s.length < 2
724
+ needle = query.to_s.downcase
725
+ hits = []
726
+ framework_doc_paths.call.each do |p|
727
+ begin
728
+ lines = File.read(p, encoding: "utf-8", invalid: :replace, undef: :replace).split("\n")
729
+ rescue StandardError
730
+ next
731
+ end
732
+ lines.each_with_index do |line, i|
733
+ next unless line.downcase.include?(needle)
734
+ start_i = [0, i - context_lines.to_i].max
735
+ end_i = [lines.size, i + context_lines.to_i + 1].min
736
+ score = 1
737
+ score += 1 if line.include?(query.to_s)
738
+ score += 2 if line.lstrip.start_with?("#")
739
+ hits << {
740
+ "file" => File.basename(p),
741
+ "line" => i + 1,
742
+ "score" => score,
743
+ "snippet" => lines[start_i...end_i].join("\n")
744
+ }
745
+ end
746
+ end
747
+ hits.sort_by! { |h| -h["score"] }
748
+ hits.first([1, limit.to_i].max)
749
+ }, "Search Tina4 framework docs for a query string")
750
+
751
+ server.register_tool("docs_section", lambda { |file:, heading:|
752
+ match = framework_doc_paths.call.find { |p| File.basename(p) == file }
753
+ return { "error" => "Unknown doc file: #{file}. Try docs_list() first." } unless match
754
+ text = File.read(match, encoding: "utf-8", invalid: :replace, undef: :replace)
755
+ lines = text.split("\n")
756
+ heading_lc = heading.to_s.downcase.strip
757
+ start_i = -1
758
+ start_level = 0
759
+ lines.each_with_index do |line, i|
760
+ stripped = line.lstrip
761
+ next unless stripped.start_with?("#")
762
+ level = stripped.length - stripped.sub(/\A#+/, "").length
763
+ title = stripped[level..].to_s.strip.downcase
764
+ if title.include?(heading_lc)
765
+ start_i = i
766
+ start_level = level
767
+ break
768
+ end
769
+ end
770
+ return { "error" => "Heading '#{heading}' not found in #{file}" } if start_i < 0
771
+ end_i = lines.size
772
+ (start_i + 1).upto(lines.size - 1) do |j|
773
+ stripped = lines[j].lstrip
774
+ next unless stripped.start_with?("#")
775
+ level = stripped.length - stripped.sub(/\A#+/, "").length
776
+ if level <= start_level
777
+ end_i = j
778
+ break
779
+ end
780
+ end
781
+ { "file" => file, "heading" => lines[start_i].strip, "body" => lines[start_i...end_i].join("\n") }
782
+ }, "Return a full markdown section from a framework doc file")
783
+
784
+ # ── Git / deps / project ──────────────────────────
785
+ server.register_tool("git_status", lambda {
786
+ Tina4::DevAdmin.send(:git_status_payload)
787
+ }, "Show git branch, modified/untracked files, recent commits")
788
+
789
+ server.register_tool("deps_list", lambda {
790
+ gemfile = File.join(Dir.pwd, "Gemfile")
791
+ return { "error" => "No Gemfile at project root" } unless File.file?(gemfile)
792
+ deps = File.read(gemfile).scan(/^\s*gem\s+["']([^"']+)["']/).flatten
793
+ { "name" => File.basename(Dir.pwd), "dependencies" => deps }
794
+ }, "List this project's declared Ruby dependencies")
795
+
796
+ server.register_tool("project_overview", lambda {
797
+ { "system" => { "framework" => "tina4-ruby", "version" => (defined?(Tina4::VERSION) ? Tina4::VERSION : "unknown"), "ruby" => RUBY_DESCRIPTION, "cwd" => project_root } }
798
+ }, "One-shot snapshot: system + project info")
799
+
800
+ # ── Project index ─────────────────────────────────
801
+ server.register_tool("index_rebuild", lambda {
802
+ Tina4::ProjectIndex.refresh
803
+ }, "Refresh the persistent project index (lazy, mtime-based)")
804
+
805
+ server.register_tool("index_search", lambda { |query:, limit: 20|
806
+ Tina4::ProjectIndex.search(query, limit.to_i)
807
+ }, "Find files by path, symbol, route, or summary")
808
+
809
+ server.register_tool("index_file", lambda { |path:|
810
+ Tina4::ProjectIndex.file_entry(path)
811
+ }, "Full index entry for one file")
812
+
813
+ server.register_tool("index_overview", lambda {
814
+ Tina4::ProjectIndex.overview
815
+ }, "Project shape: files by language, routes, models, recent edits")
816
+
817
+ # ── Plan management ───────────────────────────────
818
+ server.register_tool("plan_current", lambda {
819
+ Tina4::Plan.current
820
+ }, "The active plan: title, steps (done/not), next step, progress")
821
+
822
+ server.register_tool("plan_list", lambda {
823
+ Tina4::Plan.list_plans
824
+ }, "All plans in plan/ with progress and which one is active")
825
+
826
+ server.register_tool("plan_create", lambda { |title:, goal: "", steps: nil, make_current: true|
827
+ Tina4::Plan.create(title, goal: goal, steps: steps, make_current: make_current)
828
+ }, "Create a new markdown plan in plan/ and make it active")
829
+
830
+ server.register_tool("plan_switch_to", lambda { |name:|
831
+ Tina4::Plan.set_current(name)
832
+ }, "Make a different plan the active one")
833
+
834
+ server.register_tool("plan_complete_step", lambda { |index:|
835
+ Tina4::Plan.complete_step(index.to_i)
836
+ }, "Tick a step as done (call the moment the step finishes)")
837
+
838
+ server.register_tool("plan_add_step", lambda { |text:|
839
+ Tina4::Plan.add_step(text)
840
+ }, "Append a new unchecked step to the current plan")
841
+
842
+ server.register_tool("plan_note", lambda { |text:|
843
+ Tina4::Plan.append_note(text)
844
+ }, "Append a timestamped note/breadcrumb to the current plan")
845
+
846
+ server.register_tool("plan_archive", lambda { |name: ""|
847
+ Tina4::Plan.archive(name)
848
+ }, "Move a finished plan to plan/done/")
849
+
850
+ server.register_tool("plan_read", lambda { |name:|
851
+ Tina4::Plan.read(name)
852
+ }, "Full structured view of any plan by filename")
853
+
854
+ server.register_tool("plan_flesh", lambda { |name: "", prompt: ""|
855
+ Tina4::Plan.flesh(name, prompt)
856
+ }, "Auto-generate concrete build steps via AI and append them to an existing plan")
857
+
683
858
  # ── System Tools ──────────────────────────────────
684
859
  server.register_tool("system_info", lambda {
685
860
  {