tina4ruby 3.11.35 → 3.11.36

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: 7dfb1cf2a3f34468c680f80f120b935a59348b7bab428fa5ba8c738a2a9ddf85
4
- data.tar.gz: 7ceb6e23bc01350d5de21c77fd4d7902a2a314933a5290a6db3dac272060865a
3
+ metadata.gz: 9615f251d5bed0d97024c8e061bdf34d26afb2677e5c6ec989005d45d3edc6c9
4
+ data.tar.gz: 7ef7b684a87906e932f3c929cfe57596f67bf81bf63cf35e79b8d343f2ab297a
5
5
  SHA512:
6
- metadata.gz: f27c8ed10bc2c080ed765060989dafdc1c05c04bb255ac7a093cda62b692965e940b81d46f9b8f170ef0e3afd9cee44ee63d82eadc09410c34ef29338f1192ec
7
- data.tar.gz: 694ef83820ce2021da6ae51013259590fd70374ac2170831d039af3cb9dfa3a30ab1bf7ff691c4c8bcdaadefdf7e5ca9901c30a8a537b69ade99c8aaa8cb1c12
6
+ metadata.gz: ecf726e25d06e8ecbed12874fa4f36edf20286ea10c2c9b6c973a2d9d430dd38933a95240b8d50dd4237b82e076b634527e0c28016aa6e94f6f71a7747d126f8
7
+ data.tar.gz: bc0603a6370d2dc27f26ad5cee86c00f233213d57dc904075f70c5befdf28ff81b78d0c25ffa25a42f34ae9cf48d0a33a322e1147d12bc05db1f125db245538f
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tina4
4
+ # Periodic background task registry.
5
+ #
6
+ # Matches Python's `tina4_python.core.server.background(fn, interval)` and
7
+ # PHP's `$app->background($callback, $interval)` — a callback that runs
8
+ # periodically alongside the server lifecycle.
9
+ #
10
+ # Ruby has no asyncio event loop, so each task runs in its own thread.
11
+ # The GIL keeps it cooperative-enough for the periodic work this is meant
12
+ # for (queue draining, health checks, simulators). Errors in the callback
13
+ # are caught and logged so they don't kill the thread.
14
+ module Background
15
+ class << self
16
+ # Register a periodic callback.
17
+ #
18
+ # @param callback [#call, nil] Object responding to `call` with no args.
19
+ # @param interval [Float] Seconds between invocations (default 1.0).
20
+ # @param block [Proc] Optional block (used if callback is nil).
21
+ # @return [Hash] The registered task descriptor.
22
+ def register(callback = nil, interval: 1.0, &block)
23
+ cb = callback || block
24
+ raise ArgumentError, "background requires a callback or block" if cb.nil?
25
+ raise ArgumentError, "callback must respond to :call" unless cb.respond_to?(:call)
26
+
27
+ task = { callback: cb, interval: interval.to_f, thread: nil, running: false }
28
+ mutex.synchronize { tasks << task }
29
+ start_task(task)
30
+ task
31
+ end
32
+
33
+ # All registered task descriptors. Tests use this for introspection.
34
+ def tasks
35
+ @tasks ||= []
36
+ end
37
+
38
+ # Stop and join every running task. Called on graceful shutdown.
39
+ def stop_all(timeout: 2.0)
40
+ snapshot = mutex.synchronize { tasks.dup }
41
+ snapshot.each { |task| stop_task(task, timeout: timeout) }
42
+ mutex.synchronize { tasks.clear }
43
+ end
44
+
45
+ # Stop a single task. Used by tests that register, fire, then stop.
46
+ def stop_task(task, timeout: 2.0)
47
+ task[:running] = false
48
+ thread = task[:thread]
49
+ return unless thread
50
+
51
+ thread.join(timeout) || thread.kill
52
+ task[:thread] = nil
53
+ end
54
+
55
+ private
56
+
57
+ def mutex
58
+ @mutex ||= Mutex.new
59
+ end
60
+
61
+ def start_task(task)
62
+ task[:running] = true
63
+ task[:thread] = Thread.new do
64
+ while task[:running]
65
+ sleep task[:interval]
66
+ break unless task[:running]
67
+
68
+ begin
69
+ task[:callback].call
70
+ rescue => e
71
+ # Never let a callback error kill the thread — next interval still fires.
72
+ if defined?(Tina4::Log) && Tina4::Log.respond_to?(:error)
73
+ Tina4::Log.error("background task error: #{e.class}: #{e.message}")
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -109,6 +109,15 @@ module Tina4
109
109
  @pool_size = pool # 0 = single connection, N>0 = N pooled connections
110
110
  @connected = false
111
111
 
112
+ # Per-instance thread-local key for the transaction adapter pin.
113
+ # Without this pin, every Database method call rotates to a different
114
+ # pooled connection. Inside a transaction this silently breaks atomicity:
115
+ # start_transaction begins on adapter A, executes autocommit on B/C, and
116
+ # commit/rollback land on D — a no-op. start_transaction sets the pin,
117
+ # commit/rollback clear it. While pinned, current_driver returns the same
118
+ # driver for every call so the whole transaction runs on one connection.
119
+ @tx_pin_key = :"tina4_pinned_adapter_#{object_id}"
120
+
112
121
  # Query cache — off by default, opt-in via TINA4_DB_CACHE=true
113
122
  @cache_enabled = truthy?(ENV["TINA4_DB_CACHE"])
114
123
  @cache_ttl = (ENV["TINA4_DB_CACHE_TTL"] || "30").to_i
@@ -161,7 +170,14 @@ module Tina4
161
170
  end
162
171
 
163
172
  # Get the current driver — from pool (round-robin) or single connection.
173
+ #
174
+ # Inside a transaction, all calls must land on the SAME driver — otherwise
175
+ # start_transaction, execute, and commit each rotate to a different pooled
176
+ # connection and the transaction is meaningless. start_transaction pins
177
+ # the driver to the calling thread; commit/rollback release it.
164
178
  def current_driver
179
+ pinned = Thread.current[@tx_pin_key]
180
+ return pinned if pinned
165
181
  if @pool
166
182
  @pool.checkout
167
183
  else
@@ -355,27 +371,38 @@ module Tina4
355
371
 
356
372
  def transaction
357
373
  drv = current_driver
374
+ Thread.current[@tx_pin_key] = drv
358
375
  drv.begin_transaction
359
376
  yield self
360
377
  drv.commit
361
378
  rescue => e
362
- drv.rollback
379
+ drv.rollback if drv
363
380
  raise e
381
+ ensure
382
+ Thread.current[@tx_pin_key] = nil
364
383
  end
365
384
 
366
385
  # Begin a transaction without a block — matches PHP/Python/Node API.
386
+ # Pins the driver to this thread for the whole transaction so executes
387
+ # and the final commit/rollback all run on the same connection.
367
388
  def start_transaction
368
- current_driver.begin_transaction
389
+ drv = current_driver
390
+ Thread.current[@tx_pin_key] = drv
391
+ drv.begin_transaction
369
392
  end
370
393
 
371
- # Commit the current transaction matches PHP/Python/Node API.
394
+ # Commit the current transaction and release the driver pin.
372
395
  def commit
373
396
  current_driver.commit
397
+ ensure
398
+ Thread.current[@tx_pin_key] = nil
374
399
  end
375
400
 
376
- # Roll back the current transaction matches PHP/Python/Node API.
401
+ # Roll back the current transaction and release the driver pin.
377
402
  def rollback
378
403
  current_driver.rollback
404
+ ensure
405
+ Thread.current[@tx_pin_key] = nil
379
406
  end
380
407
 
381
408
  def tables
@@ -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
@@ -314,9 +316,55 @@ module Tina4
314
316
  Tina4::Env.is_truthy(ENV["TINA4_DEBUG"])
315
317
  end
316
318
 
319
+ # Write `.tina4/mcp.json` so MCP-aware tools (Claude Code, Cursor) can
320
+ # auto-discover this project's live docs server. Idempotent — no-op if
321
+ # the file already matches the desired contents. Also appends `.tina4/`
322
+ # to the project's `.gitignore` if a git repo is present.
323
+ def auto_discover_mcp!(project_root: Dir.pwd)
324
+ return if @_mcp_auto_discovered
325
+ @_mcp_auto_discovered = true
326
+ return unless enabled?
327
+
328
+ port = ENV["TINA4_PORT"] || ENV["PORT"] || "7147"
329
+ url = "http://localhost:#{port}/__dev/api/mcp"
330
+ target_dir = File.join(project_root, ".tina4")
331
+ target = File.join(target_dir, "mcp.json")
332
+ payload = {
333
+ "mcpServers" => {
334
+ "tina4-live-docs" => {
335
+ "url" => url,
336
+ "description" => "Live API docs for this Tina4 project (framework + user code)",
337
+ },
338
+ },
339
+ }
340
+ begin
341
+ FileUtils.mkdir_p(target_dir)
342
+ existing = if File.file?(target)
343
+ (JSON.parse(File.read(target)) rescue {})
344
+ else
345
+ {}
346
+ end
347
+ if existing != payload
348
+ File.write(target, JSON.pretty_generate(payload))
349
+ end
350
+
351
+ gitignore = File.join(project_root, ".gitignore")
352
+ if File.directory?(File.join(project_root, ".git"))
353
+ current = File.file?(gitignore) ? File.read(gitignore) : ""
354
+ unless current.lines.map(&:strip).include?(".tina4/") || current.lines.map(&:strip).include?(".tina4")
355
+ prefix = (!current.empty? && !current.end_with?("\n")) ? "\n" : ""
356
+ File.write(gitignore, "#{current}#{prefix}.tina4/\n")
357
+ end
358
+ end
359
+ rescue StandardError => e
360
+ Tina4::Log.warning("auto_discover_mcp! failed: #{e.message}") if defined?(Tina4::Log)
361
+ end
362
+ end
363
+
317
364
  # Handle a /__dev request; returns [status, headers, body] or nil if not a dev path
318
365
  def handle_request(env)
319
366
  return nil unless enabled?
367
+ auto_discover_mcp!
320
368
 
321
369
  path = env["PATH_INFO"] || "/"
322
370
  method = env["REQUEST_METHOD"]
@@ -405,7 +453,9 @@ module Tina4
405
453
  resolved = id ? error_tracker.resolve(id) : false
406
454
  json_response({ resolved: resolved, id: id })
407
455
  when ["POST", "/__dev/api/broken/clear"]
408
- error_tracker.clear_resolved
456
+ # "Clear All" button — flush every tracked error, not only the
457
+ # ones individually marked resolved. Matches PHP/Python.
458
+ error_tracker.clear_all
409
459
  json_response({ cleared: true })
410
460
  when ["GET", "/__dev/api/websockets"]
411
461
  json_response({ connections: [], count: 0 })
@@ -494,6 +544,66 @@ module Tina4
494
544
  when ["GET", "/__dev/api/metrics/file"]
495
545
  file_path = (query_param(env, "path") || "").to_s
496
546
  json_response(Tina4::Metrics.file_detail(file_path))
547
+ when ["GET", "/__dev/api/thoughts"]
548
+ json_response(thoughts_payload)
549
+ when ["POST", "/__dev/api/supervise/create"]
550
+ body = read_json_body(env) || {}
551
+ json_response(proxy_supervisor("/supervise/create", method: "POST", body: body))
552
+ when ["GET", "/__dev/api/supervise/sessions"]
553
+ json_response(proxy_supervisor("/supervise/sessions", method: "GET", query: env["QUERY_STRING"]))
554
+ when ["GET", "/__dev/api/supervise/diff"]
555
+ json_response(proxy_supervisor("/supervise/diff", method: "GET", query: env["QUERY_STRING"]))
556
+ when ["POST", "/__dev/api/supervise/commit"]
557
+ body = read_json_body(env) || {}
558
+ json_response(proxy_supervisor("/supervise/commit", method: "POST", body: body))
559
+ when ["POST", "/__dev/api/supervise/cancel"]
560
+ body = read_json_body(env) || {}
561
+ json_response(proxy_supervisor("/supervise/cancel", method: "POST", body: body))
562
+ when ["POST", "/__dev/api/execute"]
563
+ body = read_json_body(env) || {}
564
+ execute_proxy(body)
565
+ when ["GET", "/__dev/api/files"]
566
+ json_response(files_list(env))
567
+ when ["GET", "/__dev/api/file"]
568
+ json_response(file_read_payload(query_param(env, "path")))
569
+ when ["GET", "/__dev/api/file/raw"]
570
+ file_raw_response(query_param(env, "path"))
571
+ when ["POST", "/__dev/api/file/save"]
572
+ body = read_json_body(env) || {}
573
+ json_response(file_save(body))
574
+ when ["POST", "/__dev/api/file/rename"]
575
+ body = read_json_body(env) || {}
576
+ json_response(file_rename(body))
577
+ when ["POST", "/__dev/api/file/delete"]
578
+ body = read_json_body(env) || {}
579
+ json_response(file_delete(body))
580
+ when ["GET", "/__dev/api/deps/search"]
581
+ json_response(deps_search(query_param(env, "q") || query_param(env, "query") || ""))
582
+ when ["POST", "/__dev/api/deps/install"]
583
+ body = read_json_body(env) || {}
584
+ json_response(deps_install(body))
585
+ when ["GET", "/__dev/api/git/status"]
586
+ json_response(git_status_payload)
587
+ when ["GET", "/__dev/api/mcp/tools"]
588
+ json_response(mcp_tools_list)
589
+ when ["POST", "/__dev/api/mcp/call"]
590
+ body = read_json_body(env) || {}
591
+ json_response(mcp_tool_call(body))
592
+ when ["GET", "/__dev/api/scaffold"]
593
+ json_response(scaffold_templates)
594
+ when ["POST", "/__dev/api/scaffold/run"]
595
+ body = read_json_body(env) || {}
596
+ json_response(scaffold_run(body))
597
+ when ["GET", "/__dev/api/docs/search"]
598
+ json_response(docs_search_payload(env))
599
+ when ["GET", "/__dev/api/docs/class"]
600
+ json_response(docs_class_payload(query_param(env, "name")))
601
+ when ["GET", "/__dev/api/docs/method"]
602
+ json_response(docs_method_payload(query_param(env, "class"), query_param(env, "name")))
603
+ when ["GET", "/__dev/api/docs/index"]
604
+ json_response(docs_index_payload(query_param(env, "source")))
605
+ when ["GET", "/__dev/api/docs/.well-known.json"]
606
+ json_response(docs_well_known_payload)
497
607
  when ["GET", "/__dev/api/graphql/schema"]
498
608
  begin
499
609
  gql = Tina4::GraphQL.new
@@ -930,6 +1040,358 @@ module Tina4
930
1040
 
931
1041
  { deployed: name, files: copied }
932
1042
  end
1043
+
1044
+ # ── New dev-admin surface area (parity with Python/PHP) ────
1045
+
1046
+ def supervisor_base
1047
+ base = ENV["TINA4_SUPERVISOR_URL"].to_s.strip
1048
+ return base unless base.empty?
1049
+ port = (ENV["TINA4_PORT"] || ENV["PORT"] || "7147").to_i + 2000
1050
+ "http://127.0.0.1:#{port}"
1051
+ end
1052
+
1053
+ def thoughts_payload
1054
+ base = supervisor_base
1055
+ begin
1056
+ uri = URI.parse("#{base}/thoughts")
1057
+ req = Net::HTTP::Get.new(uri)
1058
+ resp = Net::HTTP.start(uri.host, uri.port, open_timeout: 2, read_timeout: 5) { |h| h.request(req) }
1059
+ return JSON.parse(resp.body) if resp.is_a?(Net::HTTPSuccess)
1060
+ { thoughts: [], error: "Supervisor returned #{resp.code}" }
1061
+ rescue StandardError => e
1062
+ { thoughts: [], error: e.message }
1063
+ end
1064
+ end
1065
+
1066
+ def proxy_supervisor(path, method: "GET", body: nil, query: nil)
1067
+ base = supervisor_base
1068
+ url = "#{base}#{path}"
1069
+ url += "?#{query}" if query && !query.empty?
1070
+ begin
1071
+ uri = URI.parse(url)
1072
+ req = case method.upcase
1073
+ when "POST"
1074
+ r = Net::HTTP::Post.new(uri)
1075
+ r["Content-Type"] = "application/json"
1076
+ r.body = JSON.generate(body || {})
1077
+ r
1078
+ else
1079
+ Net::HTTP::Get.new(uri)
1080
+ end
1081
+ resp = Net::HTTP.start(uri.host, uri.port, open_timeout: 2, read_timeout: 30) { |h| h.request(req) }
1082
+ begin
1083
+ JSON.parse(resp.body)
1084
+ rescue JSON::ParserError
1085
+ { body: resp.body, status: resp.code.to_i }
1086
+ end
1087
+ rescue StandardError => e
1088
+ { error: e.message, supervisor: base }
1089
+ end
1090
+ end
1091
+
1092
+ def execute_proxy(body)
1093
+ # Proxy POST /execute to the supervisor at framework_port + 2000.
1094
+ # Pass through the response stream as-is (SSE or JSON).
1095
+ base = supervisor_base
1096
+ begin
1097
+ uri = URI.parse("#{base}/execute")
1098
+ req = Net::HTTP::Post.new(uri)
1099
+ req["Content-Type"] = "application/json"
1100
+ req["Accept"] = "text/event-stream"
1101
+ req.body = JSON.generate(body || {})
1102
+ http = Net::HTTP.new(uri.host, uri.port)
1103
+ http.open_timeout = 2
1104
+ http.read_timeout = 300
1105
+ resp = http.request(req)
1106
+ ct = resp["content-type"] || "application/json; charset=utf-8"
1107
+ [resp.code.to_i, { "content-type" => ct }, [resp.body.to_s]]
1108
+ rescue StandardError => e
1109
+ body_str = JSON.generate({ error: e.message, supervisor: base })
1110
+ [502, { "content-type" => "application/json; charset=utf-8" }, [body_str]]
1111
+ end
1112
+ end
1113
+
1114
+ def safe_project_path(rel_path)
1115
+ root = File.expand_path(Dir.pwd)
1116
+ resolved = File.expand_path(rel_path.to_s, root)
1117
+ raise ArgumentError, "path escapes project directory" unless resolved.start_with?(root)
1118
+ resolved
1119
+ end
1120
+
1121
+ def files_list(env)
1122
+ rel = query_param(env, "path") || "."
1123
+ begin
1124
+ target = safe_project_path(rel)
1125
+ return { error: "Not found" } unless File.exist?(target)
1126
+ return { error: "Not a directory" } unless File.directory?(target)
1127
+ entries = Dir.children(target).sort.map do |name|
1128
+ full = File.join(target, name)
1129
+ {
1130
+ name: name,
1131
+ type: File.directory?(full) ? "dir" : "file",
1132
+ size: File.file?(full) ? File.size(full) : 0
1133
+ }
1134
+ end
1135
+ { path: rel, entries: entries, count: entries.size }
1136
+ rescue => e
1137
+ { error: e.message }
1138
+ end
1139
+ end
1140
+
1141
+ def file_read_payload(rel)
1142
+ return { error: "path required" } if rel.nil? || rel.empty?
1143
+ begin
1144
+ target = safe_project_path(rel)
1145
+ return { error: "Not found" } unless File.exist?(target)
1146
+ return { error: "Not a file" } unless File.file?(target)
1147
+ content = File.read(target, encoding: "utf-8", invalid: :replace, undef: :replace)
1148
+ { path: rel, content: content, bytes: File.size(target) }
1149
+ rescue => e
1150
+ { error: e.message }
1151
+ end
1152
+ end
1153
+
1154
+ def file_raw_response(rel)
1155
+ return json_response({ error: "path required" }) if rel.nil? || rel.empty?
1156
+ begin
1157
+ target = safe_project_path(rel)
1158
+ return json_response({ error: "Not found" }) unless File.file?(target)
1159
+ content = File.binread(target)
1160
+ ct = case File.extname(target).downcase
1161
+ when ".css" then "text/css"
1162
+ when ".js" then "application/javascript"
1163
+ when ".json" then "application/json"
1164
+ when ".html", ".htm" then "text/html"
1165
+ when ".png" then "image/png"
1166
+ when ".jpg", ".jpeg" then "image/jpeg"
1167
+ when ".gif" then "image/gif"
1168
+ when ".svg" then "image/svg+xml"
1169
+ else "text/plain; charset=utf-8"
1170
+ end
1171
+ [200, { "content-type" => ct }, [content]]
1172
+ rescue => e
1173
+ json_response({ error: e.message })
1174
+ end
1175
+ end
1176
+
1177
+ def file_save(body)
1178
+ rel = body["path"].to_s
1179
+ content = body["content"].to_s
1180
+ return { error: "path required" } if rel.empty?
1181
+ begin
1182
+ target = safe_project_path(rel)
1183
+ existed = File.exist?(target)
1184
+ FileUtils.mkdir_p(File.dirname(target))
1185
+ File.write(target, content, encoding: "utf-8")
1186
+ Tina4::Plan.record_action(existed ? "patched" : "created", rel) if defined?(Tina4::Plan)
1187
+ { saved: rel, bytes: content.bytesize }
1188
+ rescue => e
1189
+ { error: e.message }
1190
+ end
1191
+ end
1192
+
1193
+ def file_rename(body)
1194
+ from = body["from"].to_s
1195
+ to = body["to"].to_s
1196
+ return { error: "from/to required" } if from.empty? || to.empty?
1197
+ begin
1198
+ src = safe_project_path(from)
1199
+ dst = safe_project_path(to)
1200
+ return { error: "Source not found" } unless File.exist?(src)
1201
+ FileUtils.mkdir_p(File.dirname(dst))
1202
+ File.rename(src, dst)
1203
+ { renamed: { from: from, to: to } }
1204
+ rescue => e
1205
+ { error: e.message }
1206
+ end
1207
+ end
1208
+
1209
+ def file_delete(body)
1210
+ rel = body["path"].to_s
1211
+ return { error: "path required" } if rel.empty?
1212
+ begin
1213
+ target = safe_project_path(rel)
1214
+ return { error: "Not found" } unless File.exist?(target)
1215
+ if File.directory?(target)
1216
+ FileUtils.rm_rf(target)
1217
+ else
1218
+ File.delete(target)
1219
+ end
1220
+ { deleted: rel }
1221
+ rescue => e
1222
+ { error: e.message }
1223
+ end
1224
+ end
1225
+
1226
+ def deps_search(query)
1227
+ return { results: [], count: 0, error: "query required" } if query.to_s.strip.empty?
1228
+ begin
1229
+ uri = URI.parse("https://rubygems.org/api/v1/search.json?query=#{URI.encode_www_form_component(query)}")
1230
+ http = Net::HTTP.new(uri.host, uri.port)
1231
+ http.use_ssl = true
1232
+ http.open_timeout = 5
1233
+ http.read_timeout = 8
1234
+ resp = http.request(Net::HTTP::Get.new(uri))
1235
+ if resp.is_a?(Net::HTTPSuccess)
1236
+ gems = JSON.parse(resp.body)
1237
+ results = gems.first(20).map do |g|
1238
+ { name: g["name"], version: g["version"], info: g["info"].to_s[0, 200] }
1239
+ end
1240
+ { results: results, count: results.size }
1241
+ else
1242
+ { results: [], count: 0, error: "rubygems returned #{resp.code}" }
1243
+ end
1244
+ rescue => e
1245
+ { results: [], count: 0, error: e.message }
1246
+ end
1247
+ end
1248
+
1249
+ def deps_install(body)
1250
+ name = body["name"].to_s.strip
1251
+ return { ok: false, error: "name required" } if name.empty?
1252
+ # Append to Gemfile if not present — do NOT actually bundle install.
1253
+ gemfile = File.join(Dir.pwd, "Gemfile")
1254
+ return { ok: false, error: "No Gemfile at project root" } unless File.exist?(gemfile)
1255
+ content = File.read(gemfile)
1256
+ if content.include?("gem \"#{name}\"") || content.include?("gem '#{name}'")
1257
+ return { ok: true, gem: name, note: "already in Gemfile" }
1258
+ end
1259
+ File.open(gemfile, "a") { |f| f.write("\ngem \"#{name}\"\n") }
1260
+ { ok: true, gem: name, note: "added to Gemfile; run `bundle install`" }
1261
+ end
1262
+
1263
+ def git_status_payload
1264
+ begin
1265
+ inside = `cd #{Shellwords.escape(Dir.pwd)} && git rev-parse --is-inside-work-tree 2>/dev/null`.strip
1266
+ return { error: "Not a git repository" } if inside != "true"
1267
+ branch = `cd #{Shellwords.escape(Dir.pwd)} && git branch --show-current 2>/dev/null`.strip
1268
+ status = `cd #{Shellwords.escape(Dir.pwd)} && git status --porcelain 2>/dev/null`.strip.split("\n").reject(&:empty?)
1269
+ recent = `cd #{Shellwords.escape(Dir.pwd)} && git log --oneline -5 2>/dev/null`.strip.split("\n").reject(&:empty?)
1270
+ { branch: branch, status: status, recent_commits: recent }
1271
+ rescue => e
1272
+ { error: "git unavailable: #{e.message}" }
1273
+ end
1274
+ end
1275
+
1276
+ def mcp_tools_list
1277
+ return { tools: [], count: 0 } unless defined?(Tina4::McpServer)
1278
+ server = Tina4._default_mcp_server
1279
+ list = server.tools.values.map do |t|
1280
+ { name: t["name"], description: t["description"], schema: t["inputSchema"] }
1281
+ end
1282
+ { tools: list, count: list.size }
1283
+ end
1284
+
1285
+ def mcp_tool_call(body)
1286
+ tool_name = body["name"].to_s
1287
+ args = body["arguments"] || {}
1288
+ return { error: "tool name required" } if tool_name.empty?
1289
+ return { error: "MCP not loaded" } unless defined?(Tina4::McpServer)
1290
+ server = Tina4._default_mcp_server
1291
+ payload = JSON.generate({
1292
+ "jsonrpc" => "2.0",
1293
+ "id" => 1,
1294
+ "method" => "tools/call",
1295
+ "params" => { "name" => tool_name, "arguments" => args }
1296
+ })
1297
+ raw = server.handle_message(payload)
1298
+ return {} if raw.nil? || raw.empty?
1299
+ JSON.parse(raw)
1300
+ end
1301
+
1302
+ def scaffold_templates
1303
+ # Expose built-in scaffold targets for the dev-admin UI.
1304
+ { templates: [
1305
+ { id: "route", label: "Route file", target: "src/routes" },
1306
+ { id: "model", label: "ORM model", target: "src/orm" },
1307
+ { id: "migration", label: "SQL migration", target: "migrations" },
1308
+ { id: "middleware", label: "Middleware class", target: "src/app" }
1309
+ ] }
1310
+ end
1311
+
1312
+ def scaffold_run(body)
1313
+ kind = body["kind"].to_s
1314
+ name = body["name"].to_s.strip
1315
+ return { ok: false, error: "kind + name required" } if kind.empty? || name.empty?
1316
+ project = Dir.pwd
1317
+ case kind
1318
+ when "route"
1319
+ target = File.join(project, "src", "routes", "#{name}.rb")
1320
+ FileUtils.mkdir_p(File.dirname(target))
1321
+ File.write(target, "# #{name} routes\nTina4::Router.get(\"/api/#{name}\") do |req, res|\n res.call({ hello: \"#{name}\" })\nend\n") unless File.exist?(target)
1322
+ { ok: true, created: target.sub("#{project}/", "") }
1323
+ when "model"
1324
+ target = File.join(project, "src", "orm", "#{name}.rb")
1325
+ FileUtils.mkdir_p(File.dirname(target))
1326
+ cls = name.to_s.split(/[_-]/).map(&:capitalize).join
1327
+ 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)
1328
+ { ok: true, created: target.sub("#{project}/", "") }
1329
+ when "migration"
1330
+ ts = Time.now.strftime("%Y%m%d%H%M%S")
1331
+ target = File.join(project, "migrations", "#{ts}_#{name}.sql")
1332
+ FileUtils.mkdir_p(File.dirname(target))
1333
+ File.write(target, "-- migration: #{name}\n")
1334
+ { ok: true, created: target.sub("#{project}/", "") }
1335
+ when "middleware"
1336
+ target = File.join(project, "src", "app", "#{name}.rb")
1337
+ FileUtils.mkdir_p(File.dirname(target))
1338
+ cls = name.to_s.split(/[_-]/).map(&:capitalize).join
1339
+ File.write(target, "class #{cls}\n def self.before_check(req, res); [req, res]; end\nend\n") unless File.exist?(target)
1340
+ { ok: true, created: target.sub("#{project}/", "") }
1341
+ else
1342
+ { ok: false, error: "unknown kind: #{kind}" }
1343
+ end
1344
+ end
1345
+
1346
+ # ── Live Docs (Live API RAG) ─────────────────────────────────
1347
+
1348
+ def docs_search_payload(env)
1349
+ q = (query_param(env, "q") || "").to_s
1350
+ k = (query_param(env, "k") || "5").to_i
1351
+ source = (query_param(env, "source") || "all").to_s
1352
+ include_private = %w[1 true yes].include?((query_param(env, "include_private") || "").to_s.downcase)
1353
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
1354
+ results = Tina4::Docs.cached(Dir.pwd).search(
1355
+ q, k: k, source: source, include_private: include_private
1356
+ )
1357
+ took_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0).round(2)
1358
+ { ok: true, query: q, results: results, took_ms: took_ms }
1359
+ end
1360
+
1361
+ def docs_class_payload(name)
1362
+ spec = Tina4::Docs.cached(Dir.pwd).class_spec(name.to_s)
1363
+ return { ok: false, error: "class not found: #{name}" } if spec.nil?
1364
+ { ok: true, class: spec }
1365
+ end
1366
+
1367
+ def docs_method_payload(class_fqn, name)
1368
+ spec = Tina4::Docs.cached(Dir.pwd).method_spec(class_fqn.to_s, name.to_s)
1369
+ return { ok: false, error: "method not found: #{class_fqn}##{name}" } if spec.nil?
1370
+ { ok: true, method: spec }
1371
+ end
1372
+
1373
+ def docs_index_payload(source)
1374
+ idx = Tina4::Docs.cached(Dir.pwd).index
1375
+ idx = idx.select { |e| e[:source] == source } if source && %w[framework user vendor].include?(source.to_s)
1376
+ { ok: true, count: idx.size, entries: idx }
1377
+ end
1378
+
1379
+ def docs_well_known_payload
1380
+ {
1381
+ ok: true,
1382
+ service: "tina4-live-docs",
1383
+ version: Tina4::VERSION,
1384
+ framework: "tina4-ruby",
1385
+ endpoints: {
1386
+ search: "/__dev/api/docs/search?q=<query>&k=<int>&source=<framework|user|all>&include_private=<bool>",
1387
+ class: "/__dev/api/docs/class?name=<fqn>",
1388
+ method: "/__dev/api/docs/method?class=<fqn>&name=<method>",
1389
+ index: "/__dev/api/docs/index?source=<framework|user|all>",
1390
+ },
1391
+ mcp: { url: "/__dev/api/mcp", tools: %w[api_search api_class api_method] },
1392
+ spec: "https://tina4.com — Live API RAG plan/v3/22-LIVE-API-RAG.md",
1393
+ }
1394
+ end
933
1395
  end
934
1396
  end
935
1397
  end