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
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

.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)


111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
# File 'lib/tina4/ai.rb', line 111

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)


176
177
178
179
180
# File 'lib/tina4/ai.rb', line 176

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



103
104
105
# File 'lib/tina4/ai.rb', line 103

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

.install_claude_skills(root) ⇒ Array<String>

Copy Claude Code skill files from the framework’s templates.

Parameters:

  • root (String)

    absolute project root path

Returns:

  • (Array<String>)

    list of created/updated relative file paths



279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
# File 'lib/tina4/ai.rb', line 279

def install_claude_skills(root)
  created = []

  # Determine the framework root (where lib/tina4/ lives)
  framework_root = File.expand_path("../../..", __FILE__)

  # Copy skill directories from .claude/skills/ in the framework to the project
  framework_skills_dir = File.join(framework_root, ".claude", "skills")
  if Dir.exist?(framework_skills_dir)
    target_skills_dir = File.join(root, ".claude", "skills")
    FileUtils.mkdir_p(target_skills_dir)
    Dir.children(framework_skills_dir).each do |entry|
      skill_dir = File.join(framework_skills_dir, entry)
      next unless File.directory?(skill_dir)

      target_dir = File.join(target_skills_dir, entry)
      FileUtils.rm_rf(target_dir) if Dir.exist?(target_dir)
      FileUtils.cp_r(skill_dir, target_dir)
      rel = target_dir.sub("#{root}/", "")
      created << rel
      puts "  \e[32m✓\e[0m Updated #{rel}"
    end
  end

  # Copy claude-commands if they exist
  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

  created
end

.install_for_tool(root, tool, context) ⇒ Object



250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
# File 'lib/tina4/ai.rb', line 250

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



66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
# File 'lib/tina4/ai.rb', line 66

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_tina4_aiObject

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



320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
# File 'lib/tina4/ai.rb', line 320

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)


32
33
34
# File 'lib/tina4/ai.rb', line 32

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)


204
205
206
207
# File 'lib/tina4/ai.rb', line 204

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.



147
148
149
150
151
152
153
# File 'lib/tina4/ai.rb', line 147

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`.



183
184
185
186
187
188
189
190
191
192
193
# File 'lib/tina4/ai.rb', line 183

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”)



40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
# File 'lib/tina4/ai.rb', line 40

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.



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

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" \
    "- **tina4-developer** -- Read `.claude/skills/tina4-developer/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" \
    "See https://tina4.com for full docs."
  else
    "Tina4 Skills -- read these files before working on this project:\n" \
    "  .claude/skills/tina4-developer/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" \
    "Docs: https://tina4.com"
  end
  "#{start}\n#{body}\n#{finish}"
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


217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
# File 'lib/tina4/ai.rb', line 217

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