Module: Tina4::AI

Defined in:
lib/tina4/ai.rb

Overview

Tina4 AI -- Install AI coding assistant context files.

Simple menu-driven installer for AI tool context files. The user picks which tools they use, we install the appropriate files.

selection = Tina4::AI.show_menu(".")
Tina4::AI.install_selected(".", selection)

Constant Summary collapse

AI_TOOLS =

Ordered list of supported AI tools

[
  { name: "claude-code", description: "Claude Code", context_file: "CLAUDE.md", config_dir: ".claude" },
  { name: "cursor", description: "Cursor", context_file: ".cursorules", config_dir: ".cursor" },
  { name: "copilot", description: "GitHub Copilot", context_file: ".github/copilot-instructions.md", config_dir: ".github" },
  { name: "windsurf", description: "Windsurf", context_file: ".windsurfrules", config_dir: nil },
  { name: "aider", description: "Aider", context_file: "CONVENTIONS.md", config_dir: nil },
  { name: "cline", description: "Cline", context_file: ".clinerules", config_dir: nil },
  { name: "codex", description: "OpenAI Codex", context_file: "AGENTS.md", config_dir: nil }
].freeze
DEV_SKILL =

── Skill files (the SKILL.md system) ───────────────────────────────── tina4 ai installs the actual skills -- not just a CLAUDE.md pointer to them -- into BOTH the project (.claude/skills, so they travel with the repo) AND the user's global ~/.claude/skills (so they're available in every project). A Ruby project needs the ruby developer skill plus the two shared skills. As of 3.13.59 the developer skill is split per language: the ruby dev skill ships from the tina4-ruby release tag, while tina4-js + tina4-maintainer are the canonical shared copies served from tina4-python. Mirrors the canonical install-skills.sh.

"tina4-developer-ruby"
DEV_REFS =
%w[
  auth-and-services.md data-and-orm.md deployment.md
  routes-and-api.md templates-and-frontend.md realtime.md
].freeze
SKILLS =

skill name => { repo:, refs: [reference files] }

{
  DEV_SKILL => { repo: "tina4-ruby", refs: DEV_REFS },
  "tina4-js" => {
    repo: "tina4-python",
    refs: %w[html-and-components.md signals-and-reactivity.md persistence.md rtc.md]
  },
  "tina4-maintainer" => {
    repo: "tina4-python",
    refs: %w[cli-and-deployment.md frond-and-frontend.md routing-and-orm.md subsystems.md]
  }
}.freeze
OLD_FRAMEWORK_HEADERS =

Headers the pre-v3.13.9 installer wrote at the top of CLAUDE.md.

[
  "# Tina4 Python",
  "# Tina4 PHP",
  "# Tina4 Ruby",
  "# CLAUDE.md -- AI Developer Guide for tina4-nodejs",
  "# CLAUDE.md — AI Developer Guide for tina4-nodejs",
].freeze

Class Method Summary collapse

Class Method Details

.fetch_bytes(url, limit = 5) ⇒ String?

Fetch the bytes at url, following up to limit redirects. Returns nil on any network/HTTP failure so the caller can skip gracefully.

Parameters:

  • url (String)
  • limit (Integer) (defaults to: 5)

    max redirects to follow

Returns:

  • (String, nil)


76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
# File 'lib/tina4/ai.rb', line 76

def fetch_bytes(url, limit = 5)
  return nil if limit <= 0

  uri = URI.parse(url)
  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl = (uri.scheme == "https")
  http.open_timeout = 15
  http.read_timeout = 15

  response = http.get(uri.request_uri)
  case response
  when Net::HTTPSuccess
    response.body
  when Net::HTTPRedirection
    location = response["location"]
    location ? fetch_bytes(location, limit - 1) : nil
  end
rescue StandardError
  nil
end

.generate_context(tool_name = "claude-code") ⇒ String

Generate per-tool Tina4 Ruby context document.

Parameters:

  • tool_name (String) (defaults to: "claude-code")

    AI tool name (default: "claude-code")

Returns:

  • (String)


226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
# File 'lib/tina4/ai.rb', line 226

def generate_context(tool_name = "claude-code")
  case tool_name
  when "claude-code"
    generate_claude_code_context
  when "cursor"
    generate_cursor_context
  when "copilot"
    generate_copilot_context
  when "windsurf"
    generate_windsurf_context
  when "aider"
    generate_aider_context
  when "cline"
    generate_cline_context
  when "codex"
    generate_codex_context
  else
    generate_claude_code_context
  end
end

.has_markers?(existing, start, finish) ⇒ Boolean

True iff both start and end markers appear in order.

Returns:

  • (Boolean)


296
297
298
299
300
# File 'lib/tina4/ai.rb', line 296

def has_markers?(existing, start, finish)
  s_idx = existing.index(start)
  return false unless s_idx
  !existing.index(finish, s_idx + start.length).nil?
end

.install_all(root = ".") ⇒ Array<String>

Install context for all AI tools (non-interactive).

Parameters:

  • root (String) (defaults to: ".")

    project root directory

Returns:

  • (Array<String>)

    list of created/updated file paths



218
219
220
# File 'lib/tina4/ai.rb', line 218

def install_all(root = ".")
  install_selected(root, "all")
end

.install_claude_skills(root) ⇒ Array<String>

Install Claude Code skills + commands for a project.

Parameters:

  • root (String)

    absolute project root path

Returns:

  • (Array<String>)

    list of created/updated relative file paths



399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
# File 'lib/tina4/ai.rb', line 399

def install_claude_skills(root)
  created = []

  # Copy claude-commands if they exist (a local template shipped in the
  # gem, not a network fetch).
  framework_root = File.expand_path("../../..", __FILE__)
  commands_source = File.join(framework_root, "templates", "ai", "claude-commands")
  if Dir.exist?(commands_source)
    commands_dir = File.join(root, ".claude", "commands")
    FileUtils.mkdir_p(commands_dir)
    Dir.glob(File.join(commands_source, "*.md")).each do |skill_file|
      target = File.join(commands_dir, File.basename(skill_file))
      FileUtils.cp(skill_file, target)
      rel = target.sub("#{root}/", "")
      created << rel
    end
  end

  # Install the SKILL.md skills into the project AND the user's global
  # ~/.claude/skills, network-fetched from the release tag matching this
  # framework version. (The previous code copied from
  # framework_root/.claude/skills, which exists only in a dev/editable
  # checkout -- NOT in an installed gem, where .claude/skills sits outside
  # lib/ and is never packaged. So installed users got zero skill files,
  # only a CLAUDE.md pointer to skills that were never on disk.)
  install_skills(root).each do |skill|
    created << ".claude/skills/#{skill}/"
    puts "  \e[32m✓\e[0m Installed .claude/skills/#{skill}  (project + global)"
  end

  created
end

.install_for_tool(root, tool, context) ⇒ Object



370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
# File 'lib/tina4/ai.rb', line 370

def install_for_tool(root, tool, context)
  created = []
  context_path = File.join(root, tool[:context_file])

  # Create directories
  if tool[:config_dir]
    FileUtils.mkdir_p(File.join(root, tool[:config_dir]))
  end
  FileUtils.mkdir_p(File.dirname(context_path))

  # v3.13.9: non-destructive write -- see write_or_merge below.
  action = write_or_merge(context_path, tool[:context_file], context)
  rel = context_path.sub("#{root}/", "")
  created << rel
  puts "  \e[32m✓\e[0m #{action} #{rel}"

  # Claude-specific extras
  if tool[:name] == "claude-code"
    skills = install_claude_skills(root)
    created.concat(skills)
  end

  created
end

.install_selected(root, selection) ⇒ Array<String>

Install context files for the selected tools.

Parameters:

  • root (String)

    project root directory

  • selection (String)

    comma-separated numbers like "1,2,3" or "all"

Returns:

  • (Array<String>)

    list of created/updated file paths



181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
# File 'lib/tina4/ai.rb', line 181

def install_selected(root, selection)
  root_path = File.expand_path(root)
  created = []

  if selection.downcase == "all"
    indices = (0...AI_TOOLS.length).to_a
    do_tina4_ai = true
  else
    parts = selection.split(",").map(&:strip).reject(&:empty?)
    indices = []
    do_tina4_ai = false
    parts.each do |p|
      n = Integer(p) rescue next
      if n == 8
        do_tina4_ai = true
      elsif n >= 1 && n <= AI_TOOLS.length
        indices << (n - 1)
      end
    end
  end

  indices.each do |idx|
    tool = AI_TOOLS[idx]
    context = generate_context(tool[:name])
    files = install_for_tool(root_path, tool, context)
    created.concat(files)
  end

  install_tina4_ai if do_tina4_ai

  created
end

.install_skills(root = ".", targets = nil) ⇒ Array<String>

Install the Tina4 SKILL.md skills into the project AND the user's global ~/.claude/skills, network-fetched from the release tag matching this framework version. Returns the skills fully installed. Network-dependent -- a fetch failure skips that skill gracefully.

Parameters:

  • root (String) (defaults to: ".")

    project root directory

  • targets (Array<String>, nil) (defaults to: nil)

    .claude/skills dirs to write to; defaults to the project's + the user's global skills dir

Returns:

  • (Array<String>)

    skill names that installed cleanly



106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
# File 'lib/tina4/ai.rb', line 106

def install_skills(root = ".", targets = nil)
  ref = skills_ref
  if targets.nil?
    targets = [
      File.join(File.expand_path(root), ".claude", "skills"),
      File.join(Dir.home, ".claude", "skills")
    ]
  end

  installed = []
  SKILLS.each do |skill, meta|
    base = "https://raw.githubusercontent.com/tina4stack/#{meta[:repo]}/#{ref}/.claude/skills"
    skill_ok = true

    targets.each do |dest|
      skill_dir = File.join(dest, skill)
      FileUtils.mkdir_p(File.join(skill_dir, "references"))

      data = fetch_bytes("#{base}/#{skill}/SKILL.md")
      if data.nil?
        skill_ok = false
        next
      end
      File.binwrite(File.join(skill_dir, "SKILL.md"), data)

      meta[:refs].each do |r|
        rd = fetch_bytes("#{base}/#{skill}/references/#{r}")
        File.binwrite(File.join(skill_dir, "references", r), rd) unless rd.nil?
      end
    end

    installed << skill if skill_ok
  end
  installed
end

.install_tina4_aiObject

Install tina4-ai package (provides mdview for markdown viewing).



433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
# File 'lib/tina4/ai.rb', line 433

def install_tina4_ai
  puts "  Installing tina4-ai tools..."
  %w[pip3 pip].each do |cmd|
    next unless system("which #{cmd} > /dev/null 2>&1")

    result = `#{cmd} install --upgrade tina4-ai 2>&1`
    # Subprocess output is ASCII-8BIT — force UTF-8 with byte replacement
    # so non-ASCII content (often emitted by pip on locale mismatch)
    # doesn't crash String#strip with Encoding::CompatibilityError.
    safe_result = result.dup.force_encoding("UTF-8")
    unless safe_result.valid_encoding?
      safe_result = safe_result.encode("UTF-8", "UTF-8", invalid: :replace, undef: :replace, replace: "?")
    end
    if $?.success?
      puts "  \e[32m✓\e[0m Installed tina4-ai (mdview)"
      return
    else
      puts "  \e[33m!\e[0m #{cmd} failed: #{safe_result.strip[0..100]}"
    end
  end
  puts "  \e[33m!\e[0m Python/pip not available -- skip tina4-ai"
end

.is_installed(root, tool) ⇒ Boolean

Check if a tool's context file already exists.

Parameters:

  • root (String)

    project root directory

  • tool (Hash)

    tool entry from AI_TOOLS

Returns:

  • (Boolean)


147
148
149
# File 'lib/tina4/ai.rb', line 147

def is_installed(root, tool)
  File.exist?(File.join(File.expand_path(root), tool[:context_file]))
end

.looks_like_old_framework_install?(existing) ⇒ Boolean

Returns:

  • (Boolean)


324
325
326
327
# File 'lib/tina4/ai.rb', line 324

def looks_like_old_framework_install?(existing)
  head = existing.lstrip[0, 400] || ""
  OLD_FRAMEWORK_HEADERS.any? { |h| head.start_with?(h) }
end

.markers_for(context_file) ⇒ Object

Return [start, end] markers for the given context file.



262
263
264
265
266
267
268
# File 'lib/tina4/ai.rb', line 262

def markers_for(context_file)
  if context_file.downcase.end_with?(".md")
    ["<!-- tina4-skills:start -->", "<!-- tina4-skills:end -->"]
  else
    ["# tina4-skills:start", "# tina4-skills:end"]
  end
end

.replace_marker_block(existing, block, start, finish) ⇒ Object

Replace the bracketed block in existing with block.



303
304
305
306
307
308
309
310
311
312
313
# File 'lib/tina4/ai.rb', line 303

def replace_marker_block(existing, block, start, finish)
  s_idx = existing.index(start)
  return existing.rstrip + "\n\n" + block + "\n" unless s_idx
  e_idx = existing.index(finish, s_idx + start.length)
  return existing.rstrip + "\n\n" + block + "\n" unless e_idx
  before = existing[0...s_idx].rstrip
  after = existing[(e_idx + finish.length)..].sub(/\A\n+/, "")
  glue_before = before.empty? ? "" : "\n\n"
  glue_after = after.empty? ? "\n" : "\n" + after
  "#{before}#{glue_before}#{block}#{glue_after}"
end

.show_menu(root = ".") ⇒ String

Print the numbered menu and return user input.

Parameters:

  • root (String) (defaults to: ".")

    project root directory (default: ".")

Returns:

  • (String)

    user input (comma-separated numbers or "all")



155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
# File 'lib/tina4/ai.rb', line 155

def show_menu(root = ".")
  root = File.expand_path(root)
  green = "\e[32m"
  reset = "\e[0m"

  puts "\n  Tina4 AI Context Installer\n"
  AI_TOOLS.each_with_index do |tool, i|
    marker = is_installed(root, tool) ? "  #{green}[installed]#{reset}" : ""
    puts format("  %d. %-20s %s%s", i + 1, tool[:description], tool[:context_file], marker)
  end

  # tina4-ai tools option
  tina4_ai_installed = system("which mdview > /dev/null 2>&1")
  marker = tina4_ai_installed ? "  #{green}[installed]#{reset}" : ""
  puts "  8. Install tina4-ai tools  (requires Python)#{marker}"
  puts

  print "  Select (comma-separated, or 'all'): "
  $stdin.gets&.strip || ""
end

.skill_block(context_file) ⇒ Object

Return the marker-bracketed Tina4 skill registration block.



271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
# File 'lib/tina4/ai.rb', line 271

def skill_block(context_file)
  start, finish = markers_for(context_file)
  body = if context_file.downcase.end_with?(".md")
    "## Tina4 Skills\n\n" \
    "When working on this Tina4 project, these skills give the assistant project-aware behaviour:\n\n" \
    "- **#{DEV_SKILL}** -- Read `.claude/skills/#{DEV_SKILL}/SKILL.md` before building features.\n" \
    "- **tina4-js** -- Read `.claude/skills/tina4-js/SKILL.md` for frontend work.\n" \
    "- **tina4-maintainer** -- Read `.claude/skills/tina4-maintainer/SKILL.md` for framework-level changes.\n\n" \
    "If Tina4 behaves differently from what these skills describe, that is a bug in the skill. " \
    "Tell the developer, then report it at https://tina4.com/report-a-skill " \
    "(or open an issue on the matching tina4stack/* GitHub repo).\n\n" \
    "See https://tina4.com for full docs."
  else
    "Tina4 Skills -- read these files before working on this project:\n" \
    "  .claude/skills/#{DEV_SKILL}/SKILL.md   (feature development)\n" \
    "  .claude/skills/tina4-js/SKILL.md          (frontend / tina4-js)\n" \
    "  .claude/skills/tina4-maintainer/SKILL.md  (framework-level changes)\n" \
    "Found a skill that disagrees with how Tina4 actually behaves? Tell the developer,\n" \
    "then report it at https://tina4.com/report-a-skill\n" \
    "Docs: https://tina4.com"
  end
  "#{start}\n#{body}\n#{finish}"
end

.skills_refString

Release tag to pull skills from -- the installed framework version, overridable with TINA4_SKILLS_REF (e.g. to test a branch / tag).

Returns:

  • (String)


61
62
63
64
65
66
67
68
# File 'lib/tina4/ai.rb', line 61

def skills_ref
  ref = ENV["TINA4_SKILLS_REF"]
  return ref if ref && !ref.strip.empty?
  return Tina4::VERSION if defined?(Tina4::VERSION) && Tina4::VERSION
  "main"
rescue StandardError
  "main"
end

.write_or_merge(context_path, context_file, framework_guide) ⇒ Object

Write the context file non-destructively. Returns a human-readable action verb for the caller's log line.

Four branches:

1. Doesn't exist  -> write framework guide + skill block
2. Has markers    -> refresh just the skill block (idempotent)
3. Old header     -> migrate: replace old dump with new guide + block
4. User content   -> append the skill block, preserve everything else


337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
# File 'lib/tina4/ai.rb', line 337

def write_or_merge(context_path, context_file, framework_guide)
  # Force UTF-8 — CLAUDE.md and the framework guide both contain
  # non-ASCII (em-dashes, ✓, etc.). Without this, File.read may
  # return ASCII-8BIT and string concat raises CompatibilityError.
  block = skill_block(context_file).dup.force_encoding("UTF-8")
  guide = framework_guide.dup.force_encoding("UTF-8")
  start, finish = markers_for(context_file)

  unless File.exist?(context_path)
    File.write(context_path, guide.rstrip + "\n\n" + block + "\n", encoding: "UTF-8")
    return "Installed"
  end

  existing = File.read(context_path, encoding: "UTF-8")

  if has_markers?(existing, start, finish)
    File.write(context_path, replace_marker_block(existing, block, start, finish), encoding: "UTF-8")
    return "Refreshed skill block in"
  end

  if looks_like_old_framework_install?(existing)
    head = existing.lstrip
    preamble = existing[0, existing.length - head.length] || ""
    new_content = (preamble.strip.empty? ? "" : preamble.rstrip + "\n\n") +
                  guide.rstrip + "\n\n" + block + "\n"
    File.write(context_path, new_content, encoding: "UTF-8")
    return "Migrated (replaced old framework dump in)"
  end

  File.write(context_path, existing.rstrip + "\n\n" + block + "\n", encoding: "UTF-8")
  "Appended skill block to"
end