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.(".")
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
-
.generate_context(tool_name = "claude-code") ⇒ String
Generate per-tool Tina4 Ruby context document.
-
.has_markers?(existing, start, finish) ⇒ Boolean
True iff both start and end markers appear in order.
-
.install_all(root = ".") ⇒ Array<String>
Install context for all AI tools (non-interactive).
-
.install_claude_skills(root) ⇒ Array<String>
Copy Claude Code skill files from the framework’s templates.
- .install_for_tool(root, tool, context) ⇒ Object
-
.install_selected(root, selection) ⇒ Array<String>
Install context files for the selected tools.
-
.install_tina4_ai ⇒ Object
Install tina4-ai package (provides mdview for markdown viewing).
-
.is_installed(root, tool) ⇒ Boolean
Check if a tool’s context file already exists.
- .looks_like_old_framework_install?(existing) ⇒ Boolean
-
.markers_for(context_file) ⇒ Object
Return [start, end] markers for the given context file.
-
.replace_marker_block(existing, block, start, finish) ⇒ Object
Replace the bracketed block in ‘existing` with `block`.
-
.show_menu(root = ".") ⇒ String
Print the numbered menu and return user input.
-
.skill_block(context_file) ⇒ Object
Return the marker-bracketed Tina4 skill registration block.
-
.write_or_merge(context_path, context_file, framework_guide) ⇒ Object
Write the context file non-destructively.
Class Method Details
.generate_context(tool_name = "claude-code") ⇒ String
Generate per-tool Tina4 Ruby context document.
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.
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).
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.
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.("../../..", __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.
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.(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_ai ⇒ Object
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.
32 33 34 |
# File 'lib/tina4/ai.rb', line 32 def is_installed(root, tool) File.exist?(File.join(File.(root), tool[:context_file])) end |
.looks_like_old_framework_install?(existing) ⇒ 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.
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 (root = ".") root = File.(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 |