tinker-agent 1.0.59 → 1.0.61

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.
@@ -85,9 +85,16 @@ chmod +x /etc/profile.d/claude.sh
85
85
  echo "Installing Github CLI"
86
86
  # Install GitHub CLI
87
87
  curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
88
+ chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg
88
89
  echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list
89
90
  apt-get update && apt-get install gh -y
90
91
 
92
+ # Download setup-agent.rb script
93
+ TINKER_VERSION=${TINKER_VERSION:-main}
94
+ echo "Downloading setup-agent.rb (version: ${TINKER_VERSION})..."
95
+ curl -fsSL https://objectstore.fra1.civo.com/tinker/${TINKER_VERSION}/setup-agent.rb -o /usr/local/bin/setup-agent.rb
96
+ chmod +x /usr/local/bin/setup-agent.rb
97
+
91
98
  # Create entrypoint
92
99
  cat << EOF > /entrypoint.sh
93
100
  #!/bin/bash
@@ -240,7 +240,6 @@ module TinkerAgent
240
240
  possible_paths = [
241
241
  File.join(Dir.pwd, "overrides", "agent-bridge-linux-#{linux_arch}"), # TINKER_SANDBOX/overrides
242
242
  File.join(Dir.pwd, "tinker-public", "dist", "agent-bridge-linux-#{linux_arch}"), # Running from metafolder
243
- File.join(Dir.pwd, "..", "tinker-public", "dist", "agent-bridge-linux-#{linux_arch}"), # Running from TINKER_SANDBOX
244
243
  File.join(Dir.pwd, "dist", "agent-bridge-linux-#{linux_arch}"), # Running from tinker-public
245
244
  ]
246
245
 
package/package.json CHANGED
@@ -1,14 +1,11 @@
1
1
  {
2
2
  "name": "tinker-agent",
3
- "version": "1.0.59",
3
+ "version": "1.0.61",
4
4
  "description": "Tinker Agent Runner",
5
- "bin": {
6
- "tinker-agent": "./bin/run-tinker-agent.rb"
7
- },
5
+ "bin": "./bin/run-tinker-agent.rb",
8
6
  "files": [
9
7
  "bin/run-tinker-agent.rb",
10
8
  "agents.rb",
11
- "setup-agent.rb",
12
9
  "lib/",
13
10
  "bin/agent-bridge-tmux",
14
11
  "bin/install-agent.sh"
package/setup-agent.rb DELETED
@@ -1,961 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # frozen_string_literal: true
3
-
4
- # Tinker Agent - Run inside any Docker container with Ruby
5
- #
6
- # This script sets up and runs a Tinker agent. It:
7
- # 1. Generates MCP config from environment variables
8
- # 2. Verifies system prompt (agent banner) exists at /etc/tinker/system-prompt.txt
9
- # 3. Downloads and runs agent-bridge
10
- #
11
- # Requirements (install in your container):
12
- # - Ruby 3.x
13
- # - Node.js 20+
14
- # - tmux
15
- # - git, curl, gh (GitHub CLI)
16
- # - claude CLI: npm install -g @anthropic-ai/claude-code
17
- #
18
- # Environment variables:
19
- # AGENT_TYPE - worker|planner|reviewer|researcher
20
- # PROJECT_ID - Your Tinker project ID
21
- # RAILS_WS_URL - WebSocket URL (wss://tinker.example.com/cable)
22
- # RAILS_API_URL - API URL (https://tinker.example.com/api/v1)
23
- # RAILS_API_KEY - MCP API key for this agent type
24
- # GH_TOKEN - GitHub token (or use GitHub App auth)
25
- #
26
- # Usage:
27
- # curl -fsSL https://raw.githubusercontent.com/RoM4iK/tinker-public/main/tinker-agent.rb | ruby
28
-
29
- require "json"
30
- require "fileutils"
31
- require "open-uri"
32
- require "openssl"
33
- require "net/http"
34
- require "uri"
35
- require "base64"
36
- require "time"
37
-
38
- TINKER_VERSION = ENV["TINKER_VERSION"] || "main"
39
- TINKER_RAW_URL = "https://raw.githubusercontent.com/RoM4iK/tinker-public/#{TINKER_VERSION}"
40
-
41
- # Valid agent types
42
- VALID_AGENT_TYPES = %w[planner worker reviewer researcher]
43
-
44
- def check_env!
45
- required = %w[RAILS_WS_URL]
46
- missing = required.select { |var| ENV[var].to_s.empty? }
47
-
48
- unless missing.empty?
49
- puts "❌ Missing environment variables: #{missing.join(', ')}"
50
- puts ""
51
- puts "Required:"
52
- puts " RAILS_WS_URL - WebSocket URL (wss://tinker.example.com/cable)"
53
- puts ""
54
- puts "Optional:"
55
- puts " RAILS_API_URL - API URL for MCP tools"
56
- puts " RAILS_API_KEY - MCP API key"
57
- puts " GH_TOKEN - GitHub token"
58
- exit 1
59
- end
60
-
61
- agent_type = ENV["AGENT_TYPE"]
62
- if agent_type && !VALID_AGENT_TYPES.include?(agent_type)
63
- puts "❌ Invalid AGENT_TYPE: #{agent_type}"
64
- puts " Valid types: #{VALID_AGENT_TYPES.join(', ')}"
65
- exit 1
66
- end
67
- end
68
-
69
- def fetch_agent_id!
70
- rails_api_url = ENV["RAILS_API_URL"]
71
- rails_api_key = ENV["RAILS_API_KEY"]
72
-
73
- unless rails_api_url && !rails_api_url.empty? && rails_api_key && !rails_api_key.empty?
74
- puts "❌ RAILS_API_URL or RAILS_API_KEY not set"
75
- puts " Agent ID is required for the bridge to connect"
76
- exit 1
77
- end
78
-
79
- # Fetch agent ID from Rails API using api_key
80
- uri = URI("#{rails_api_url}/whoami")
81
- http = Net::HTTP.new(uri.host, uri.port)
82
- http.use_ssl = uri.scheme == "https"
83
-
84
- request = Net::HTTP::Get.new(uri)
85
- request["X-API-Key"] = rails_api_key
86
- request["Accept"] = "application/json"
87
-
88
- begin
89
- puts "🔍 Fetching agent ID from #{rails_api_url}/whoami..."
90
- response = http.request(request)
91
-
92
- if response.is_a?(Net::HTTPSuccess)
93
- data = JSON.parse(response.body)
94
- agent_id = data["agent_id"] || data["id"]
95
-
96
- if agent_id && !agent_id.to_s.empty?
97
- ENV["AGENT_ID"] = agent_id.to_s
98
- puts "✅ Agent ID fetched: #{agent_id}"
99
- else
100
- puts "❌ Could not find agent_id in API response"
101
- puts " Response: #{response.body}"
102
- exit 1
103
- end
104
- else
105
- puts "❌ Failed to fetch agent ID: #{response.code} #{response.message}"
106
- puts " Response: #{response.body}"
107
- exit 1
108
- end
109
- rescue => e
110
- puts "❌ Error fetching agent ID: #{e.message}"
111
- puts " #{e.class}: #{e.backtrace.first}"
112
- exit 1
113
- end
114
- end
115
-
116
- def setup_mcp_config!
117
- # MCP config is project-specific and should be provided by the Dockerfile
118
- # or mounted at runtime. This script only checks if it exists.
119
-
120
- agent_type = ENV["AGENT_TYPE"]
121
- rails_api_url = ENV["RAILS_API_URL"]
122
- rails_api_key = ENV["RAILS_API_KEY"]
123
-
124
- # Load existing config if present
125
- existing_config = {}
126
- if File.exist?(".mcp.json")
127
- begin
128
- existing_config = JSON.parse(File.read(".mcp.json"))
129
- puts "✅ Found existing .mcp.json, merging configuration..."
130
- rescue JSON::ParserError
131
- puts "⚠️ Existing .mcp.json is invalid, starting fresh"
132
- end
133
- end
134
-
135
- # Ensure mcpServers key exists
136
- existing_config["mcpServers"] ||= {}
137
-
138
- if rails_api_url && !rails_api_url.empty? && rails_api_key && !rails_api_key.empty?
139
- # Install tinker-mcp locally to ensure we can run it with node (bypassing shebang issues)
140
- tools_dir = File.expand_path("~/tinker-tools")
141
- FileUtils.mkdir_p(tools_dir)
142
-
143
- puts "📦 Installing tinker-mcp..."
144
- # Redirect output to avoid cluttering logs, unless it fails
145
- unless system("npm install --prefix #{tools_dir} tinker-mcp > /dev/null 2>&1")
146
- puts "❌ Failed to install tinker-mcp"
147
- end
148
-
149
- script_path = "#{tools_dir}/node_modules/tinker-mcp/dist/index.js"
150
-
151
- tinker_server_config = {
152
- "command" => "node",
153
- "args" => [script_path],
154
- "env" => {
155
- "RAILS_API_URL" => rails_api_url,
156
- "RAILS_API_KEY" => rails_api_key
157
- }
158
- }
159
-
160
- # Add/Update tinker server config
161
- existing_config["mcpServers"]["tinker-#{agent_type}"] = tinker_server_config
162
-
163
- File.write(".mcp.json", JSON.pretty_generate(existing_config))
164
- puts "📝 Updated .mcp.json with tinker-#{agent_type} server (using tinker-mcp)"
165
- else
166
- # Only write if we don't have existing config
167
- if existing_config["mcpServers"].empty?
168
- File.write(".mcp.json", JSON.generate({ "mcpServers" => {} }))
169
- puts "ℹ️ No MCP API credentials - MCP tools disabled"
170
- else
171
- puts "ℹ️ No MCP API credentials - keeping existing config"
172
- end
173
- end
174
- end
175
-
176
- def setup_claude_config!
177
- home_claude_json = File.expand_path("~/.claude.json")
178
-
179
- # First, try to copy from mounted config (from host)
180
- mounted_paths = ["/tmp/cfg/claude.json"]
181
- mounted_paths.each do |path|
182
- if File.exist?(path) && !File.exist?(home_claude_json)
183
- FileUtils.cp(path, home_claude_json)
184
- puts "📋 Copied claude.json from #{path}"
185
- break
186
- end
187
- end
188
-
189
- if File.exist?(home_claude_json)
190
- puts "🔧 Configuring claude.json..."
191
- begin
192
- claude_config = JSON.parse(File.read(home_claude_json))
193
-
194
- # Add bypass permission at top level
195
- claude_config["bypassPermissionsModeAccepted"] = true
196
-
197
- File.write(home_claude_json, JSON.pretty_generate(claude_config))
198
- puts "✅ claude.json configured with bypass permissions"
199
- rescue JSON::ParserError
200
- puts "⚠️ claude.json is invalid, skipping configuration"
201
- end
202
- else
203
- puts "⚠️ claude.json not found at #{home_claude_json}"
204
- end
205
- end
206
-
207
- def setup_claude_settings!
208
- settings_dir = File.expand_path("~/.claude")
209
- FileUtils.mkdir_p(settings_dir)
210
- home_settings_json = File.join(settings_dir, "settings.json")
211
-
212
- puts "🔧 Configuring ~/.claude/settings.json..."
213
-
214
- settings_config = {}
215
-
216
- # First, try to load user's existing settings from mounted config
217
- # We merge both files: settings.json AND claude.json (for theme/appearance settings)
218
- mounted_settings_paths = [
219
- "/tmp/cfg/settings.json",
220
- "/tmp/cfg/claude.json"
221
- ]
222
-
223
- mounted_settings = {}
224
- mounted_settings_paths.each do |path|
225
- if File.exist?(path)
226
- begin
227
- file_settings = JSON.parse(File.read(path))
228
- # Merge settings - later files override earlier ones for conflicting keys
229
- mounted_settings = mounted_settings.merge(file_settings)
230
- puts " (loaded from #{path})"
231
- rescue JSON::ParserError => e
232
- puts " ⚠️ Failed to parse #{path}: #{e.message}"
233
- end
234
- end
235
- end
236
-
237
- if mounted_settings.any?
238
- settings_config = mounted_settings
239
- elsif File.exist?(home_settings_json)
240
- begin
241
- settings_config = JSON.parse(File.read(home_settings_json))
242
- rescue JSON::ParserError
243
- puts "⚠️ ~/.claude/settings.json is invalid, starting fresh"
244
- end
245
- end
246
-
247
- # Configure hooks
248
- settings_config["hooks"] ||= {}
249
-
250
-
251
- # Configure SessionStart hook for skill knowledge injection
252
- settings_config["hooks"]["SessionStart"] ||= []
253
- # Remove old hook if present
254
- settings_config["hooks"]["SessionStart"].reject! do |h|
255
- h["hooks"]&.first&.dig("command")&.include?("inject-skill-knowledge.rb")
256
- end
257
-
258
- # Install hook script to ~/.claude/hooks
259
- hooks_dir = File.join(settings_dir, "hooks")
260
- FileUtils.mkdir_p(hooks_dir)
261
-
262
- session_script_name = "inject-skill-knowledge.rb"
263
- dest_path = File.join(hooks_dir, session_script_name)
264
-
265
- # Try local copy first (dev mode), otherwise download
266
- local_hook_paths = [
267
- File.join(Dir.pwd, "tinker-public", "hooks", session_script_name),
268
- File.join(Dir.pwd, "hooks", session_script_name)
269
- ]
270
-
271
- local_hook_content = nil
272
- local_hook_paths.each do |path|
273
- if File.exist?(path)
274
- local_hook_content = File.read(path)
275
- puts " (using local hook: #{path})"
276
- break
277
- end
278
- end
279
-
280
- if local_hook_content
281
- File.write(dest_path, local_hook_content)
282
- File.chmod(0755, dest_path)
283
- puts "✅ Installed SessionStart hook (from local) to #{dest_path}"
284
- else
285
- puts "📥 Downloading SessionStart hook..."
286
- hook_url = "#{TINKER_RAW_URL}/hooks/#{session_script_name}"
287
- begin
288
- hook_content = URI.open(hook_url).read
289
- File.write(dest_path, hook_content)
290
- File.chmod(0755, dest_path)
291
- puts "✅ Installed SessionStart hook (from remote) to #{dest_path}"
292
- rescue OpenURI::HTTPError => e
293
- puts "⚠️ Failed to download hook: #{e.message}"
294
- end
295
- end
296
-
297
- session_hook_to_add = {
298
- "hooks" => [{ "type" => "command", "command" => "ruby \"#{dest_path}\"" }]
299
- }
300
-
301
- settings_config["hooks"]["SessionStart"] << session_hook_to_add
302
-
303
- # Install sessionstart-input.sh for repository context injection
304
- # This provides repository information to Claude at session start
305
- input_script_name = "sessionstart-input.sh"
306
- input_dest_path = File.join(hooks_dir, input_script_name)
307
-
308
- local_input_paths = [
309
- File.join(Dir.pwd, "tinker-public", "hooks", input_script_name),
310
- File.join(Dir.pwd, "hooks", input_script_name)
311
- ]
312
-
313
- local_input_content = nil
314
- local_input_paths.each do |path|
315
- if File.exist?(path)
316
- local_input_content = File.read(path)
317
- puts " (using local input hook: #{path})"
318
- break
319
- end
320
- end
321
-
322
- if local_input_content
323
- File.write(input_dest_path, local_input_content)
324
- File.chmod(0755, input_dest_path)
325
- puts "✅ Installed SessionStart input hook (from local) to #{input_dest_path}"
326
- else
327
- puts "📥 Downloading SessionStart input hook..."
328
- input_hook_url = "#{TINKER_RAW_URL}/hooks/#{input_script_name}"
329
- begin
330
- input_hook_content = URI.open(input_hook_url).read
331
- File.write(input_dest_path, input_hook_content)
332
- File.chmod(0755, input_dest_path)
333
- puts "✅ Installed SessionStart input hook (from remote) to #{input_dest_path}"
334
- rescue OpenURI::HTTPError => e
335
- puts "⚠️ Failed to download input hook: #{e.message}"
336
- end
337
- end
338
-
339
- File.write(home_settings_json, JSON.pretty_generate(settings_config))
340
- puts "✅ ~/.claude/settings.json configured with skill hooks"
341
- end
342
-
343
- def setup_system_prompt!
344
- if File.exist?("/etc/tinker/system-prompt.txt")
345
- puts "✅ System prompt available at /etc/tinker/system-prompt.txt"
346
- else
347
- puts "❌ /etc/tinker/system-prompt.txt not found!"
348
- exit 1
349
- end
350
-
351
- # Add repository context to CLAUDE.md for easy reference
352
- claude_md = File.expand_path("~/CLAUDE.md")
353
- if ENV["REPOSITORIES_CONTEXT"]
354
- repo_info = ENV["REPOSITORIES_CONTEXT"].split(',').map do |repo|
355
- name, path = repo.split(':')
356
- " - **#{name}**: #{path}"
357
- end.join("\n")
358
-
359
- repo_context_md = <<~MARKDOWN
360
- ## Repository Structure
361
-
362
- This project has #{ENV["REPOSITORIES_CONTEXT"].split(',').count} repositories:
363
-
364
- #{repo_info}
365
-
366
- ### Repository Switching
367
-
368
- Use `switch-to-repo.sh <name>` to change working directories:
369
-
370
- ```bash
371
- switch-to-repo.sh tinker
372
- switch-to-repo.sh tinker-public
373
- ```
374
- MARKDOWN
375
-
376
- # Append to existing CLAUDE.md or create new one
377
- existing_content = File.exist?(claude_md) ? File.read(claude_md) : ""
378
- File.write(claude_md, existing_content + "\n" + repo_context_md)
379
- puts "📝 Repository context added to ~/CLAUDE.md"
380
- end
381
- end
382
-
383
-
384
- def setup_skills!
385
- skills = ENV["SKILLS"].to_s.split(",")
386
- return if skills.empty?
387
-
388
- skills_dir = File.expand_path("~/.claude/skills")
389
- puts "🧠 Installing #{skills.size} skills to #{skills_dir}..."
390
- FileUtils.mkdir_p(skills_dir)
391
-
392
- skills.each do |skill|
393
- puts " - #{skill}"
394
-
395
- # Try local copy first (dev mode, or if copied into image)
396
- # Check both PWD/tinker-public/skills or PWD/skills (depending on how image was built)
397
- local_paths = [
398
- File.join(Dir.pwd, "tinker-public", "skills", skill, "SKILL.md")
399
- ]
400
-
401
- skill_content = nil
402
-
403
- local_paths.each do |path|
404
- if File.exist?(path)
405
- skill_content = File.read(path)
406
- puts " (from local: #{path})"
407
- break
408
- end
409
- end
410
-
411
- url = "#{TINKER_RAW_URL}/skills/#{skill}/SKILL.md"
412
-
413
- begin
414
- unless skill_content
415
- skill_content = URI.open(url).read
416
- end
417
-
418
- # Write to .claude/skills/[skill]/SKILL.md with proper structure
419
- skill_dir = File.join(skills_dir, skill)
420
- FileUtils.mkdir_p(skill_dir)
421
- File.write(File.join(skill_dir, "SKILL.md"), skill_content)
422
- rescue OpenURI::HTTPError, Errno::ENOENT => e
423
- puts "⚠️ Failed to download skill #{skill}: #{e.message}"
424
- end
425
- end
426
-
427
- puts "✅ Skills installed"
428
- end
429
-
430
- def setup_github_auth!
431
- app_id = ENV["GITHUB_APP_ID"] || ENV["GITHUB_APP_CLIENT_ID"]
432
-
433
- # Check for private key in typical locations
434
- # 1. In the home directory (copied by entrypoint)
435
- # 2. In /tmp (mounted by docker)
436
- private_key_path = [
437
- File.expand_path("~/.github-app-privkey.pem"),
438
- "/tmp/github-app-privkey.pem"
439
- ].find { |path| File.exist?(path) }
440
-
441
- if app_id && ENV["GITHUB_APP_INSTALLATION_ID"] && private_key_path
442
- puts "🔐 Configuring GitHub App authentication..."
443
-
444
- # Create helper script
445
- helper_content = <<~RUBY
446
- #!/usr/bin/env ruby
447
- require 'openssl'
448
- require 'json'
449
- require 'net/http'
450
- require 'uri'
451
- require 'base64'
452
- require 'time'
453
-
454
- PRIVATE_KEY_PATH = "#{private_key_path}"
455
-
456
- def generate_jwt(app_id)
457
- private_key = OpenSSL::PKey::RSA.new(File.read(PRIVATE_KEY_PATH))
458
- payload = {
459
- iat: Time.now.to_i - 60,
460
- exp: Time.now.to_i + 480, # 8 minutes to handle clock skew
461
- iss: app_id
462
- }
463
- header = { alg: 'RS256', typ: 'JWT' }
464
- segments = [
465
- Base64.urlsafe_encode64(header.to_json, padding: false),
466
- Base64.urlsafe_encode64(payload.to_json, padding: false)
467
- ]
468
- signing_input = segments.join('.')
469
- signature = private_key.sign(OpenSSL::Digest::SHA256.new, signing_input)
470
- segments << Base64.urlsafe_encode64(signature, padding: false)
471
- segments.join('.')
472
- end
473
-
474
- def get_installation_token(jwt, installation_id)
475
- uri = URI("https://api.github.com/app/installations/\#{installation_id}/access_tokens")
476
- http = Net::HTTP.new(uri.host, uri.port)
477
- http.use_ssl = true
478
- request = Net::HTTP::Post.new(uri)
479
- request['Authorization'] = "Bearer \#{jwt}"
480
- request['Accept'] = 'application/vnd.github+json'
481
-
482
- response = http.request(request)
483
- data = JSON.parse(response.body)
484
-
485
- unless response.is_a?(Net::HTTPSuccess)
486
- STDERR.puts "❌ Error getting installation token: \#{response.code} \#{response.message}"
487
- STDERR.puts " Response: \#{response.body}"
488
- exit 1
489
- end
490
-
491
- { token: data['token'], expires_at: Time.parse(data['expires_at']) }
492
- end
493
-
494
- def find_or_create_cached_token(app_id, installation_id)
495
- cache_file = '/tmp/github-app-token-cache'
496
- cached_token = read_cached_token(cache_file)
497
- return cached_token if cached_token
498
-
499
- jwt = generate_jwt(app_id)
500
- token_data = get_installation_token(jwt, installation_id)
501
- File.write(cache_file, token_data.to_json)
502
- token_data[:token]
503
- end
504
-
505
- def read_cached_token(cache_file)
506
- return nil unless File.exist?(cache_file)
507
- cache = JSON.parse(File.read(cache_file))
508
- return if cache['token'].nil? || cache['expires_at'].nil?
509
- return cache['token'] if Time.parse(cache['expires_at']) > Time.now + 300
510
- nil
511
- rescue
512
- end
513
-
514
- app_id = ENV['GITHUB_APP_CLIENT_ID'] || ENV['GITHUB_APP_ID']
515
- installation_id = ENV['GITHUB_APP_INSTALLATION_ID']
516
-
517
- puts find_or_create_cached_token(app_id, installation_id)
518
- RUBY
519
-
520
- # Install helper via sudo to /usr/local/bin
521
- helper_path = "/usr/local/bin/git-auth-helper"
522
- File.write("/tmp/git-auth-helper", helper_content)
523
- system("sudo mv /tmp/git-auth-helper #{helper_path}")
524
- system("sudo chmod +x #{helper_path}")
525
-
526
- # Configure git
527
- system("git config --global credential.helper '!f() { test \"$1\" = get && echo \"protocol=https\" && echo \"host=github.com\" && echo \"username=x-access-token\" && echo \"password=$(#{helper_path})\"; }; f'")
528
-
529
- # Configure gh CLI wrapper for auto-refresh and permission controls
530
- if install_gh_wrapper!(real_gh_path: "/usr/bin/gh", with_token_refresh: true, token_helper_path: helper_path)
531
- puts "✅ GitHub App authentication configured (with auto-refresh + permission controls)"
532
- end
533
-
534
- elsif ENV["GH_TOKEN"] && !ENV["GH_TOKEN"].empty?
535
- system("echo '#{ENV['GH_TOKEN']}' | gh auth login --with-token 2>/dev/null")
536
-
537
- # Install wrapper for permission controls even with GH_TOKEN
538
- if install_gh_wrapper!(real_gh_path: "/usr/bin/gh")
539
- puts "🔐 GitHub authentication configured (with permission controls)"
540
- else
541
- puts "🔐 GitHub authentication configured"
542
- end
543
- else
544
- puts "⚠️ No GH_TOKEN or GitHub App config - GitHub operations may fail"
545
- end
546
- end
547
-
548
- def install_gh_wrapper!(real_gh_path:, with_token_refresh: false, token_helper_path: nil)
549
- unless File.exist?(real_gh_path)
550
- puts "⚠️ Could not find 'gh' at #{real_gh_path}, skipping wrapper"
551
- return false
552
- end
553
-
554
- # Move real gh to gh.real if not already done
555
- unless File.exist?("#{real_gh_path}.real")
556
- system("sudo mv #{real_gh_path} #{real_gh_path}.real")
557
- end
558
-
559
- wrapper_path = "/usr/local/bin/gh"
560
-
561
- # Build token export line if auto-refresh is enabled
562
- token_export = with_token_refresh ? "export GH_TOKEN=$(#{token_helper_path})\n\n" : ""
563
-
564
- wrapper_content = <<~BASH
565
- #!/bin/bash
566
- # GitHub CLI wrapper with permission controls for agents
567
- #{token_export}# Check if caller is an agent (read AGENT_TYPE at runtime)
568
- if [ -n "$AGENT_TYPE" ] && [[ "$AGENT_TYPE" =~ ^(worker|planner|reviewer|researcher)$ ]]; then
569
- # Block comment-related commands for agents
570
- if [[ "$1" == "comment" ]] || ([[ "$1" == "pr" ]] && [[ "$2" == "comment" ]]); then
571
- echo "❌ You cannot use 'gh $*' commands as an agent." >&2
572
- echo "" >&2
573
- echo "Tinker has its own comment system via the add_comment MCP tool." >&2
574
- echo "Please use the add_comment tool instead of gh commands." >&2
575
- echo "" >&2
576
- echo "Example:" >&2
577
- echo " add_comment(ticket_id: 123, content: \\"Your comment here\\", comment_type: \\"note\\")" >&2
578
- exit 1
579
- fi
580
- fi
581
-
582
- # Execute real gh binary with all arguments
583
- exec #{real_gh_path}.real "$@"
584
- BASH
585
-
586
- File.write("/tmp/gh-wrapper", wrapper_content)
587
- system("sudo mv /tmp/gh-wrapper #{wrapper_path}")
588
- system("sudo chmod +x #{wrapper_path}")
589
- true
590
- end
591
-
592
- def clone_repositories!
593
- return unless ENV["REPOSITORIES_CONFIG"]
594
-
595
- begin
596
- repos = JSON.parse(ENV["REPOSITORIES_CONFIG"])
597
- return unless repos.is_a?(Array) && repos.any?
598
-
599
- puts "📚 Cloning #{repos.count} repositories..."
600
-
601
- repos.each do |repo|
602
- repo_name = repo["name"]
603
- github_url = repo["github_url"]
604
- sandbox_path = repo["path_in_sandbox"] || "/workspace/#{repo_name}"
605
-
606
- # Create parent directory if needed
607
- parent_dir = File.dirname(sandbox_path)
608
- FileUtils.mkdir_p(parent_dir) unless File.exist?(parent_dir)
609
-
610
- # Clone repository if it doesn't exist
611
- unless File.exist?(sandbox_path)
612
- puts " 📥 Cloning #{repo_name} to #{sandbox_path}..."
613
- system("git", "clone", "--depth", "1", github_url, sandbox_path)
614
- else
615
- puts " ✓ #{repo_name} already exists at #{sandbox_path}"
616
- end
617
- end
618
-
619
- puts "✅ Repositories cloned"
620
-
621
- # Write repository context to a file for hooks to read
622
- # This is more reliable than environment variables in tmux sessions
623
- if ENV["REPOSITORIES_CONTEXT"]
624
- repo_context_file = File.expand_path("~/.claude/repos_context.txt")
625
- File.write(repo_context_file, ENV["REPOSITORIES_CONTEXT"])
626
- puts "📝 Repository context written to #{repo_context_file}"
627
- end
628
-
629
- # Export REPOSITORIES_CONTEXT to bash profile for shell sessions
630
- if ENV["REPOSITORIES_CONTEXT"]
631
- profile_file = File.expand_path("~/.bashrc")
632
- existing_content = File.exist?(profile_file) ? File.read(profile_file) : ""
633
-
634
- # Remove old export if present
635
- existing_content.gsub!(/^export REPOSITORIES_CONTEXT=.*$/, "")
636
-
637
- # Add new export at the end
638
- File.write(profile_file, existing_content + "\nexport REPOSITORIES_CONTEXT=\"#{ENV['REPOSITORIES_CONTEXT']}\"\n")
639
- puts "📝 REPOSITORIES_CONTEXT exported to ~/.bashrc"
640
- end
641
- rescue JSON::ParserError => e
642
- puts "⚠️ Failed to parse REPOSITORIES_CONFIG: #{e.message}"
643
- end
644
- end
645
-
646
- def setup_git_config!
647
- # Configure identity if provided
648
- if ENV["GIT_USER_NAME"] && !ENV["GIT_USER_NAME"].empty?
649
- system("git config --global user.name \"#{ENV['GIT_USER_NAME']}\"")
650
- puts "✅ Git user.name configured"
651
- end
652
-
653
- if ENV["GIT_USER_EMAIL"] && !ENV["GIT_USER_EMAIL"].empty?
654
- system("git config --global user.email \"#{ENV['GIT_USER_EMAIL']}\"")
655
- puts "✅ Git user.email configured"
656
- end
657
-
658
- # Force HTTPS instead of SSH to ensure our token auth works
659
- # This fixes "Permission denied (publickey)" when the repo uses git@github.com remote
660
- system("git config --global url.\"https://github.com/\".insteadOf \"git@github.com:\"")
661
- puts "✅ Git configured to force HTTPS for GitHub"
662
-
663
- # Avoid "dubious ownership" errors in containers
664
- system("git config --global --add safe.directory \"#{Dir.pwd}\"")
665
- puts "✅ Git configured to trust #{Dir.pwd}"
666
- end
667
-
668
-
669
- def setup_git_hooks!
670
- # Install git hooks to encourage agents to use git-workflow skill
671
- hooks_dest = File.join(Dir.pwd, ".git", "hooks")
672
- return unless File.directory?(hooks_dest)
673
-
674
- puts "🪝 Installing git hooks..."
675
-
676
- # Try local copy first, then download from GitHub
677
- local_paths = [
678
- File.join(Dir.pwd, "tinker-public", "hooks", "pre-commit"),
679
- File.join(Dir.pwd, "hooks", "pre-commit"),
680
- "/tmp/hooks/pre-commit"
681
- ]
682
-
683
- hook_content = nil
684
- local_paths.each do |path|
685
- if File.exist?(path)
686
- hook_content = File.read(path)
687
- puts " (from local: #{path})"
688
- break
689
- end
690
- end
691
-
692
- unless hook_content
693
- url = "#{TINKER_RAW_URL}/hooks/pre-commit"
694
- begin
695
- hook_content = URI.open(url).read
696
- rescue OpenURI::HTTPError => e
697
- puts "⚠️ Failed to download pre-commit hook: #{e.message}"
698
- return
699
- end
700
- end
701
-
702
- hook_path = File.join(hooks_dest, "pre-commit")
703
- File.write(hook_path, hook_content)
704
- File.chmod(0755, hook_path)
705
- puts "✅ Git hooks installed"
706
- end
707
-
708
- def setup_multi_repo_scripts!
709
- # Install switch-to-repo.sh script and repo-specific setup scripts
710
- # These are only needed when using multi-repo setup
711
- repos_dir = File.expand_path("~/repos")
712
- bin_dir = "/usr/local/bin"
713
-
714
- puts "📚 Setting up multi-repo scripts..."
715
-
716
- # Create repos directory for repo-specific setup scripts
717
- FileUtils.mkdir_p(repos_dir)
718
-
719
- # Install switch-to-repo.sh script
720
- switch_script = File.join(bin_dir, "switch-to-repo.sh")
721
-
722
- # Try local copy first, then download from GitHub
723
- local_paths = [
724
- File.join(Dir.pwd, "tinker-public", "hooks", "switch-to-repo.sh"),
725
- File.join(Dir.pwd, "hooks", "switch-to-repo.sh"),
726
- "/tmp/hooks/switch-to-repo.sh"
727
- ]
728
-
729
- script_content = nil
730
- local_paths.each do |path|
731
- if File.exist?(path)
732
- script_content = File.read(path)
733
- puts " (from local: #{path})"
734
- break
735
- end
736
- end
737
-
738
- unless script_content
739
- url = "#{TINKER_RAW_URL}/hooks/switch-to-repo.sh"
740
- begin
741
- script_content = URI.open(url).read
742
- rescue OpenURI::HTTPError => e
743
- puts "⚠️ Failed to download switch-to-repo.sh: #{e.message}"
744
- return
745
- end
746
- end
747
-
748
- # Write to temp file first, then sudo mv
749
- temp_script = "/tmp/switch-to-repo.sh.temp"
750
- File.write(temp_script, script_content)
751
- system("sudo mv #{temp_script} #{switch_script}")
752
- system("sudo chmod +x #{switch_script}")
753
- puts "✅ Installed switch-to-repo.sh to #{bin_dir}"
754
-
755
- # Install repo-specific setup scripts
756
- # These are example scripts that can be customized per repository
757
- local_repos_dir = File.join(Dir.pwd, "tinker-public", "repos")
758
-
759
- if File.directory?(local_repos_dir)
760
- Dir.glob(File.join(local_repos_dir, "*.sh")).each do |script_file|
761
- script_name = File.basename(script_file)
762
- dest_script = File.join(repos_dir, script_name)
763
- FileUtils.cp(script_file, dest_script)
764
- File.chmod(0755, dest_script)
765
- puts " - Installed #{script_name}"
766
- end
767
- puts "✅ Installed repo-specific setup scripts"
768
- else
769
- puts "ℹ️ No repo-specific setup scripts found (skipping)"
770
- end
771
- end
772
-
773
- def download_agent_bridge!
774
- # Detect architecture
775
- arch = `uname -m`.strip
776
- if arch == "x86_64"
777
- arch = "amd64"
778
- elsif arch == "aarch64" || arch == "arm64"
779
- arch = "arm64"
780
- else
781
- puts "❌ Unsupported architecture: #{arch}"
782
- exit 1
783
- end
784
-
785
- target_dir = "/usr/local/bin"
786
-
787
- # 1. Check for local override in /tmp/overrides (development mode)
788
- override_dir = "/tmp/overrides"
789
- if Dir.exist?(override_dir)
790
- local_override = File.join(override_dir, "agent-bridge-linux-#{arch}")
791
- if File.exist?(local_override)
792
- puts "🔧 Using local override: #{local_override}"
793
- system("sudo cp #{local_override} #{target_dir}/agent-bridge")
794
- system("sudo chmod +x #{target_dir}/agent-bridge")
795
- puts "✅ agent-bridge installed from local override"
796
- return download_agent_bridge_tmux!(target_dir)
797
- end
798
- end
799
-
800
- # 2. Check if binaries are mounted at /tmp (dev mode)
801
- if File.exist?("/tmp/agent-bridge")
802
- puts "🔧 Installing mounted agent-bridge..."
803
- system("sudo cp /tmp/agent-bridge #{target_dir}/agent-bridge")
804
- system("sudo chmod +x #{target_dir}/agent-bridge")
805
- puts "✅ agent-bridge installed from mount"
806
- return download_agent_bridge_tmux!(target_dir)
807
- end
808
-
809
- # 3. Try Civo bucket download (production mode)
810
- if ENV["CIVO_ACCESS_KEY_ID"] && ENV["CIVO_SECRET_KEY"]
811
- puts "📥 Downloading agent-bridge from Civo bucket..."
812
- if download_from_civo!(arch, target_dir)
813
- return download_agent_bridge_tmux!(target_dir)
814
- else
815
- puts "⚠️ Civo download failed, falling back to GitHub..."
816
- end
817
- end
818
-
819
- # 4. Fallback to GitHub raw URLs (legacy)
820
- bridge_url = "#{TINKER_RAW_URL}/bin/agent-bridge-linux-#{arch}"
821
- puts "📥 Downloading agent-bridge for linux-#{arch} from GitHub..."
822
- system("sudo curl -fsSL #{bridge_url} -o #{target_dir}/agent-bridge")
823
- system("sudo chmod +x #{target_dir}/agent-bridge")
824
-
825
- puts "✅ agent-bridge installed to #{target_dir}"
826
- download_agent_bridge_tmux!(target_dir)
827
- end
828
-
829
- def download_from_civo!(arch, target_dir)
830
- require "aws-sdk-s3"
831
-
832
- region = ENV["CIVO_REGION"] || "LON1"
833
- endpoint = ENV["CIVO_ENDPOINT"] || "https://objectstore.fra1.civo.com"
834
- bucket = ENV["TINKER_BINARIES_BUCKET"] || "tinker"
835
- version = ENV["AGENT_BRIDGE_VERSION"] || "latest"
836
-
837
- binary_name = "agent-bridge-linux-#{arch}"
838
- key = "agent-bridge/#{version}/#{binary_name}"
839
-
840
- puts " Region: #{region}"
841
- puts " Bucket: #{bucket}"
842
- puts " Version: #{version}"
843
- puts " Binary: #{binary_name}"
844
-
845
- begin
846
- # Configure Civo S3-compatible client
847
- Aws.config.update({
848
- region: region,
849
- endpoint: endpoint,
850
- access_key_id: ENV["CIVO_ACCESS_KEY_ID"],
851
- secret_access_key: ENV["CIVO_SECRET_KEY"]
852
- })
853
-
854
- s3 = Aws::S3::Resource.new
855
- obj = s3.bucket(bucket).object(key)
856
-
857
- # Generate presigned URL (valid for 1 hour)
858
- url = obj.presigned_url(:get, expires_in: 3600)
859
-
860
- puts "✅ Generated presigned URL"
861
-
862
- # Download via curl
863
- system("sudo curl -fsSL #{url} -o #{target_dir}/agent-bridge")
864
-
865
- unless $?.success?
866
- puts "❌ Failed to download agent-bridge from Civo"
867
- return false
868
- end
869
-
870
- system("sudo chmod +x #{target_dir}/agent-bridge")
871
- puts "✅ Downloaded agent-bridge from Civo"
872
- return true
873
-
874
- rescue LoadError
875
- puts "⚠️ aws-sdk-s3 gem not installed"
876
- return false
877
- rescue Aws::S3::Errors::NoSuchKey
878
- puts "❌ Version #{version} not found in bucket"
879
- return false
880
- rescue => e
881
- puts "❌ Civo download failed: #{e.message}"
882
- return false
883
- end
884
- end
885
-
886
- def download_agent_bridge_tmux!(target_dir)
887
- bridge_tmux_url = "#{TINKER_RAW_URL}/bin/agent-bridge-tmux"
888
-
889
- if File.exist?("/tmp/agent-bridge-tmux")
890
- puts "🔧 Installing mounted agent-bridge-tmux..."
891
- system("sudo cp /tmp/agent-bridge-tmux #{target_dir}/agent-bridge-tmux")
892
- system("sudo chmod +x #{target_dir}/agent-bridge-tmux")
893
- else
894
- system("sudo curl -fsSL #{bridge_tmux_url} -o #{target_dir}/agent-bridge-tmux")
895
- system("sudo chmod +x #{target_dir}/agent-bridge-tmux")
896
- end
897
-
898
- # Patch agent-bridge-tmux to force INSIDE_TMUX=1
899
- # Note: This is now fixed in the repo, but we keep this for backward compatibility
900
- # with older agent-bridge-tmux scripts if cached
901
- puts "🔧 Patching agent-bridge-tmux to force INSIDE_TMUX=1..."
902
-
903
- # Read the file (we can read /usr/local/bin files usually)
904
- content = File.read("#{target_dir}/agent-bridge-tmux")
905
-
906
- # Replace the command if not already present
907
- unless content.include?("export INSIDE_TMUX=1")
908
- new_content = content.gsub(
909
- "&& agent-bridge\"",
910
- "&& export INSIDE_TMUX=1 && agent-bridge\""
911
- )
912
-
913
- # Write to temp file
914
- File.write("/tmp/agent-bridge-tmux-patched", new_content)
915
-
916
- # Move to destination with sudo
917
- system("sudo mv /tmp/agent-bridge-tmux-patched #{target_dir}/agent-bridge-tmux")
918
- system("sudo chmod +x #{target_dir}/agent-bridge-tmux")
919
- end
920
-
921
- return target_dir
922
- end
923
-
924
- def run_agent!(bin_dir)
925
- agent_type = ENV["AGENT_TYPE"] || "agent"
926
- puts ""
927
- puts "🚀 Starting #{agent_type}..."
928
- puts " Press Ctrl+B then D to detach from tmux"
929
- puts ""
930
-
931
- # Build environment variables to pass to agent-bridge-tmux
932
- # We need to explicitly export AGENT_ID if it was fetched
933
- env_vars = {}
934
- env_vars["AGENT_ID"] = ENV["AGENT_ID"] if ENV["AGENT_ID"]
935
-
936
- # Run agent-bridge-tmux which handles tmux session and status bar
937
- exec(env_vars, "#{bin_dir}/agent-bridge-tmux")
938
- end
939
-
940
- # Main
941
- puts "🤖 Tinker Agent Setup"
942
- puts "====================="
943
- puts ""
944
-
945
- check_env!
946
- fetch_agent_id!
947
- setup_mcp_config!
948
- setup_system_prompt!
949
- # Clone repositories BEFORE setting up hooks that depend on them
950
- setup_github_auth!
951
- setup_git_config!
952
- clone_repositories!
953
- setup_claude_config!
954
- setup_claude_settings!
955
- # setup_skill_hooks! # Deprecated in favor of global hooks
956
- setup_skills!
957
- setup_git_hooks!
958
- setup_multi_repo_scripts!
959
- # prepare_git_state! is now in entrypoint.sh
960
- bin_dir = download_agent_bridge!
961
- run_agent!(bin_dir)