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 +4 -4
- data/lib/tina4/dev_admin.rb +354 -0
- data/lib/tina4/frond.rb +62 -0
- data/lib/tina4/mcp.rb +175 -0
- data/lib/tina4/plan.rb +471 -0
- data/lib/tina4/project_index.rb +366 -0
- data/lib/tina4/public/js/tina4-dev-admin.js +937 -238
- data/lib/tina4/public/js/tina4-dev-admin.min.js +993 -209
- data/lib/tina4/request.rb +13 -0
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4.rb +2 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a33bdb237a1acedfe4903a9145ded69e405e9e63403c9a8c2e548795060d2740
|
|
4
|
+
data.tar.gz: bf61f8a4a84be8f910c9767a23437ec8fbc4820fb3eb05da3ef73657b16f2585
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1c43c2556e8b8b742a1cb1cb52e26b011b1c64b56cdfb636fbfdf569789f0c7fa0d25094a201cda3d301fc9982832acfe60c01c4221b033e58a41dc6b06e7eea
|
|
7
|
+
data.tar.gz: 8b38f262f9a5e41f3635000b22668200127c20cfe864bc18b316758ebbefeb58c4a4022961e929b0213a3a0e1657becf2348cdbcecf79c37f3f9561f7fa3bbdc
|
data/lib/tina4/dev_admin.rb
CHANGED
|
@@ -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
|
{
|