tinker-agent 1.0.44 → 1.0.46

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.
@@ -29,21 +29,19 @@ else
29
29
  apt-get install -y nodejs
30
30
  fi
31
31
 
32
- # Install Claude CLI
33
- npm install -g @anthropic-ai/claude-code
34
-
35
- # Install GitHub CLI
36
- curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
37
- 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
38
- apt-get update && apt-get install gh -y
39
-
40
- # Setup User
32
+ # Setup User FIRST (before installing Claude)
41
33
  # We want to run as the same UID as the host user (passed via build arg)
42
34
  # to ensure we can edit mounted files.
43
35
  USER_ID=${USER_ID:-1000}
44
36
  GROUP_ID=${GROUP_ID:-1000}
45
37
  AGENT_USER="claude"
46
38
 
39
+ # Allow UIDs outside the default range (e.g., macOS UIDs like 501)
40
+ if [ "$USER_ID" -lt 1000 ]; then
41
+ sed -i 's/^UID_MIN.*/UID_MIN 100/' /etc/login.defs
42
+ sed -i 's/^GID_MIN.*/GID_MIN 100/' /etc/login.defs
43
+ fi
44
+
47
45
  # 1. Handle Group
48
46
  if getent group ${GROUP_ID} >/dev/null 2>&1; then
49
47
  # Group exists (e.g. 'node'), use it
@@ -75,6 +73,21 @@ echo "${AGENT_USER} ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers
75
73
  # 4. Determine Home Directory
76
74
  AGENT_HOME=$(getent passwd ${AGENT_USER} | cut -d: -f6)
77
75
 
76
+ echo "Installing claude-code as ${AGENT_USER}"
77
+
78
+ # Install Claude CLI as the agent user
79
+ sudo -u ${AGENT_USER} bash -c 'curl -fsSL https://claude.ai/install.sh | bash'
80
+
81
+ # Add agent user's local bin to system PATH
82
+ echo "export PATH=\"${AGENT_HOME}/.local/bin:\$PATH\"" >> /etc/profile.d/claude.sh
83
+ chmod +x /etc/profile.d/claude.sh
84
+
85
+ echo "Installing Github CLI"
86
+ # Install GitHub CLI
87
+ curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
88
+ 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
+ apt-get update && apt-get install gh -y
90
+
78
91
  # Create entrypoint
79
92
  cat << EOF > /entrypoint.sh
80
93
  #!/bin/bash
@@ -93,12 +106,13 @@ if [ -f "/tmp/github-app-privkey.pem" ]; then
93
106
  chmod 600 ${AGENT_HOME}/.github-app-privkey.pem 2>/dev/null || true
94
107
  fi
95
108
 
96
- # Fix permissions
109
+ # Fix permissions of home directory
97
110
  sudo chown -R ${AGENT_USER}:${GROUP_NAME} ${AGENT_HOME} || echo "⚠️ Failed to chown home"
98
111
 
99
- # Fix permissions of the current directory (project root)
100
- # This ensures the agent can write CLAUDE.md and .mcp.json
101
- sudo chown ${AGENT_USER}:${GROUP_NAME} $(pwd) || echo "⚠️ Failed to chown project root"
112
+ # Fix permissions of the current directory (project root) and key subdirectories
113
+ # This ensures the agent can write CLAUDE.md, .mcp.json, and create/modify files
114
+ WORKDIR=\$(pwd)
115
+ sudo chown -R ${AGENT_USER}:${GROUP_NAME} "\${WORKDIR}" || echo "⚠️ Failed to chown workdir"
102
116
 
103
117
  # Execute command as agent user
104
118
  exec sudo -E -u ${AGENT_USER} env "HOME=${AGENT_HOME}" "\$@"
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TinkerAgent
4
+ module Agent
5
+ def self.run(agent_type, config, agent_configs)
6
+ unless agent_configs.keys.include?(agent_type)
7
+ puts "❌ Unknown agent type: #{agent_type}"
8
+ puts " Available: #{agent_configs.keys.join(', ')}"
9
+ exit 1
10
+ end
11
+
12
+ agent_def = agent_configs[agent_type]
13
+ agent_config = config.dig("agents", agent_type) || {}
14
+ container_name = agent_config["container_name"] || agent_def[:name]
15
+
16
+ puts "🚀 Starting #{agent_type} agent..."
17
+
18
+ # Stop existing container if running
19
+ system("docker", "rm", "-f", container_name, err: File::NULL, out: File::NULL)
20
+
21
+ # Write banner to a persistent temp file (not auto-deleted)
22
+ banner_path = "/tmp/tinker-agent-banner-#{agent_type}.txt"
23
+ File.write(banner_path, agent_def[:banner])
24
+
25
+ docker_cmd = build_docker_command(container_name, agent_type, config, agent_config, agent_def, banner_path)
26
+
27
+ success = system(*docker_cmd)
28
+
29
+ if success
30
+ puts "✅ Agent started in background"
31
+ puts ""
32
+ puts " Attach: npx tinker-agent attach #{agent_type}"
33
+ puts " Logs: docker logs -f #{container_name}"
34
+ puts " Stop: docker stop #{container_name}"
35
+ else
36
+ puts "❌ Failed to start agent"
37
+ exit 1
38
+ end
39
+ end
40
+
41
+ def self.attach(agent_type, config, agent_configs)
42
+ unless agent_configs.keys.include?(agent_type)
43
+ puts "❌ Unknown agent type: #{agent_type}"
44
+ exit 1
45
+ end
46
+
47
+ agent_def = agent_configs[agent_type]
48
+ agent_config = config.dig("agents", agent_type) || {}
49
+ container_name = agent_config["container_name"] || agent_def[:name]
50
+
51
+ running = `docker ps --filter name=^#{container_name}$ --format '{{.Names}}'`.strip
52
+
53
+ if running.empty?
54
+ puts "⚠️ #{agent_type} agent is not running. Auto-starting..."
55
+ Docker.build_image(config)
56
+ run(agent_type, config, agent_configs)
57
+ sleep 3
58
+ end
59
+
60
+ puts "📎 Attaching to #{agent_type} agent..."
61
+
62
+ user = detect_agent_user(container_name)
63
+ puts " User: #{user}"
64
+
65
+ # Wait for tmux session to be ready
66
+ 10.times do
67
+ if system("docker", "exec", "-u", user, container_name, "tmux", "has-session", "-t", "agent", err: File::NULL, out: File::NULL)
68
+ break
69
+ end
70
+ sleep 1
71
+ end
72
+
73
+ # Attach to agent session which has the status bar
74
+ # Must run as agent user since tmux server runs under that user
75
+ exec("docker", "exec", "-it", "-u", user, container_name, "tmux", "attach", "-t", "agent")
76
+ end
77
+
78
+ private
79
+
80
+ def self.build_docker_command(container_name, agent_type, config, agent_config, agent_def, banner_path)
81
+ docker_cmd = [
82
+ "docker", "run", "-d",
83
+ "--name", container_name,
84
+ "--network", "host",
85
+ "--restart", "unless-stopped",
86
+ "--tmpfs", "/rails/tmp",
87
+ "--tmpfs", "/rails/log"
88
+ ]
89
+
90
+ # Inject custom environment variables from config
91
+ # Merge global env with agent-specific env (agent-specific takes precedence)
92
+ merged_env = {}
93
+ merged_env.merge!(config["env"]) if config["env"]
94
+ merged_env.merge!(agent_config["env"]) if agent_config["env"]
95
+
96
+ if merged_env.any?
97
+ merged_env.each do |k, v|
98
+ docker_cmd += ["-e", "#{k}=#{v}"]
99
+ end
100
+ global_count = config["env"]&.size || 0
101
+ agent_count = agent_config["env"]&.size || 0
102
+ if agent_count > 0
103
+ puts "🌿 Injected #{global_count} global + #{agent_count} agent-specific env vars"
104
+ else
105
+ puts "🌿 Injected #{global_count} custom env vars from config"
106
+ end
107
+ end
108
+
109
+ docker_cmd += [
110
+ # Mount Claude config
111
+ "-v", "#{ENV['HOME']}/.claude.json:/tmp/cfg/claude.json:ro",
112
+ "-v", "#{ENV['HOME']}/.claude:/tmp/cfg/claude_dir:ro",
113
+ "-v", "#{banner_path}:/etc/tinker/system-prompt.txt:ro",
114
+ "-e", "TINKER_VERSION=main",
115
+ "-e", "SKILLS=#{agent_def[:skills]&.join(',')}",
116
+ "-e", "AGENT_TYPE=#{agent_type}",
117
+ "-e", "PROJECT_ID=#{config['project_id']}",
118
+ "-e", "RAILS_WS_URL=#{config['rails_ws_url']}",
119
+ "-e", "RAILS_API_URL=#{config['rails_api_url']}",
120
+ "-e", "RAILS_API_KEY=#{agent_config['mcp_api_key']}"
121
+ ]
122
+
123
+ add_github_auth!(docker_cmd, config)
124
+ add_git_config!(docker_cmd, config)
125
+ add_development_mounts!(docker_cmd)
126
+
127
+ local_setup_script = File.join(File.dirname(__FILE__), "..", "..", "setup-agent.rb")
128
+
129
+ if File.exist?(local_setup_script)
130
+ docker_cmd += [Config.image_name(config), "ruby", "/tmp/setup-agent.rb"]
131
+ else
132
+ docker_cmd += [Config.image_name(config)]
133
+ end
134
+
135
+ docker_cmd
136
+ end
137
+
138
+ def self.add_github_auth!(docker_cmd, config)
139
+ github = config["github"] || {}
140
+
141
+ if github["method"] == "app"
142
+ key_path = File.expand_path(github["app_private_key_path"].to_s)
143
+
144
+ unless File.exist?(key_path) && !File.directory?(key_path)
145
+ puts "❌ Error: GitHub App private key not found at: #{key_path}"
146
+ puts " Please check 'app_private_key_path' in tinker.env.rb"
147
+ exit 1
148
+ end
149
+
150
+ docker_cmd.concat([
151
+ "-e", "GITHUB_APP_CLIENT_ID=#{github['app_client_id']}",
152
+ "-e", "GITHUB_APP_INSTALLATION_ID=#{github['app_installation_id']}",
153
+ "-v", "#{key_path}:/tmp/github-app-privkey.pem:ro"
154
+ ])
155
+ puts "🔐 Using GitHub App authentication"
156
+ elsif github["token"]
157
+ docker_cmd.concat(["-e", "GH_TOKEN=#{github['token']}"])
158
+ puts "🔑 Using GitHub token authentication"
159
+ else
160
+ puts "❌ Error: No GitHub authentication configured"
161
+ puts " Please configure 'github' in tinker.env.rb"
162
+ exit 1
163
+ end
164
+ end
165
+
166
+ def self.add_git_config!(docker_cmd, config)
167
+ return unless (git_config = config["git"])
168
+
169
+ docker_cmd.concat(["-e", "GIT_USER_NAME=#{git_config['user_name']}"]) if git_config["user_name"]
170
+ docker_cmd.concat(["-e", "GIT_USER_EMAIL=#{git_config['user_email']}"]) if git_config["user_email"]
171
+ end
172
+
173
+ def self.add_development_mounts!(docker_cmd)
174
+ # Check for local setup-agent.rb (for development)
175
+ local_setup_script = File.join(File.dirname(__FILE__), "..", "..", "setup-agent.rb")
176
+
177
+ # Check for local agent-bridge binaries (for development)
178
+ arch = `uname -m`.strip
179
+ linux_arch = (arch == "x86_64") ? "amd64" : "arm64"
180
+ linux_bridge = File.join(Dir.pwd, "tinker-public", "bin", "agent-bridge-linux-#{linux_arch}")
181
+
182
+ local_bridge_default = File.join(Dir.pwd, "bin", "agent-bridge")
183
+ local_tmux = File.join(File.dirname(__FILE__), "..", "..", "bin", "agent-bridge-tmux")
184
+
185
+ if File.exist?(local_setup_script)
186
+ puts "🔧 Using local setup-agent.rb for development"
187
+ docker_cmd.concat(["-v", "#{File.expand_path(local_setup_script)}:/tmp/setup-agent.rb:ro"])
188
+ end
189
+
190
+ if File.exist?(linux_bridge)
191
+ puts "🔧 Using local linux binary: #{linux_bridge}"
192
+ docker_cmd.concat(["-v", "#{linux_bridge}:/tmp/agent-bridge:ro"])
193
+ elsif File.exist?(local_bridge_default)
194
+ # Check if it's a binary or script
195
+ is_script = File.read(local_bridge_default, 4) == "#!/b"
196
+ if is_script
197
+ puts "⚠️ bin/agent-bridge is a host wrapper script. Please run 'bin/build-bridge' to generate linux binaries."
198
+ else
199
+ puts "🔧 Using local agent-bridge binary"
200
+ docker_cmd.concat(["-v", "#{local_bridge_default}:/tmp/agent-bridge:ro"])
201
+ end
202
+ end
203
+
204
+ if File.exist?(local_tmux)
205
+ docker_cmd.concat(["-v", "#{File.expand_path(local_tmux)}:/tmp/agent-bridge-tmux:ro"])
206
+ end
207
+ end
208
+
209
+ def self.detect_agent_user(container_name)
210
+ # Determine the user to attach as
211
+ # Robust method: find the user running the agent process (tmux or bridge)
212
+ user = `docker exec #{container_name} ps aux | grep "[a]gent-bridge-tmux" | awk '{print $1}' | head -n 1`.strip
213
+
214
+ if user.empty?
215
+ user = `docker exec #{container_name} ps aux | grep "[t]mux new-session" | awk '{print $1}' | head -n 1`.strip
216
+ end
217
+
218
+ if user.empty?
219
+ # Fallback to previous heuristic
220
+ detected_user = `docker exec #{container_name} whoami 2>/dev/null`.strip
221
+ if detected_user == "root" || detected_user.empty?
222
+ uid = Process.uid
223
+ mapped_user = `docker exec #{container_name} getent passwd #{uid} | cut -d: -f1`.strip
224
+ user = mapped_user unless mapped_user.empty?
225
+ else
226
+ user = detected_user
227
+ end
228
+ end
229
+
230
+ if user.empty?
231
+ # Final Fallback
232
+ user = "rails"
233
+ puts "⚠️ Could not detect agent user, defaulting to '#{user}'"
234
+ end
235
+
236
+ user
237
+ end
238
+ end
239
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module TinkerAgent
6
+ module Config
7
+ def self.load
8
+ rb_config_file = File.join(Dir.pwd, "tinker.env.rb")
9
+
10
+ unless File.exist?(rb_config_file)
11
+ puts "❌ Error: tinker.env.rb not found in current directory"
12
+ puts ""
13
+ puts "Create tinker.env.rb:"
14
+ puts " {"
15
+ puts " project_id: 1,"
16
+ puts " rails_ws_url: '...',"
17
+ puts " # ..."
18
+ puts " # Paste your stripped .env content here:"
19
+ puts " dot_env: <<~ENV"
20
+ puts " STRIPE_KEY=sk_test_..."
21
+ puts " OPENAI_KEY=sk-..."
22
+ puts " ENV"
23
+ puts " }"
24
+ puts " echo 'tinker.env.rb' >> .gitignore"
25
+ exit 1
26
+ end
27
+
28
+ puts "⚙️ Loading configuration from tinker.env.rb"
29
+ config = eval(File.read(rb_config_file), binding, rb_config_file)
30
+
31
+ # Convert symbols to strings for easier handling before JSON normalization
32
+ config = config.transform_keys(&:to_s)
33
+
34
+ # Parse dot_env heredoc if present
35
+ if (dotenv = config["dot_env"])
36
+ config["env"] ||= {}
37
+ # Ensure env is string-keyed
38
+ config["env"] = config["env"].transform_keys(&:to_s)
39
+
40
+ dotenv.each_line do |line|
41
+ line = line.strip
42
+ next if line.empty? || line.start_with?('#')
43
+ k, v = line.split('=', 2)
44
+ next unless k && v
45
+ # Remove surrounding quotes and trailing comments (simple)
46
+ v = v.strip.gsub(/^['"]|['"]$/, '')
47
+ config["env"][k.strip] = v
48
+ end
49
+
50
+ config.delete("dot_env")
51
+ puts "🌿 Parsed dot_env into #{config['env'].size} environment variables"
52
+ end
53
+
54
+ # Normalize per-agent env hashes
55
+ if config["agents"].is_a?(Hash)
56
+ config["agents"].each do |agent_key, agent_config|
57
+ agent_key = agent_key.to_s
58
+ if agent_config.is_a?(Hash) && agent_config["env"].is_a?(Hash)
59
+ agent_config["env"] = agent_config["env"].transform_keys(&:to_s)
60
+ elsif agent_config.is_a?(Hash) && agent_config[:env].is_a?(Hash)
61
+ agent_config["env"] = agent_config[:env].transform_keys(&:to_s)
62
+ end
63
+ end
64
+ end
65
+
66
+ # Normalize symbols to strings for consistency via JSON round-trip
67
+ JSON.parse(JSON.generate(config))
68
+ end
69
+
70
+ def self.image_name(config)
71
+ if config["project_id"]
72
+ "tinker-sandbox-#{config['project_id']}"
73
+ else
74
+ "tinker-sandbox"
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module TinkerAgent
6
+ module Docker
7
+ def self.check_dockerfile!
8
+ unless File.exist?("Dockerfile.sandbox")
9
+ puts "❌ Error: Dockerfile.sandbox not found"
10
+ puts ""
11
+ puts "Please create Dockerfile.sandbox by copying your existing Dockerfile"
12
+ puts "and adding the required agent dependencies."
13
+ puts ""
14
+ puts "See https://github.com/RoM4iK/tinker-public/blob/main/README.md for instructions."
15
+ exit 1
16
+ end
17
+ end
18
+
19
+ def self.build_image(config)
20
+ check_dockerfile!
21
+
22
+ user_id = `id -u`.strip
23
+ group_id = `id -g`.strip
24
+
25
+ puts "🏗️ Building Docker image..."
26
+
27
+ # Handle .dockerignore.sandbox
28
+ dockerignore_sandbox = ".dockerignore.sandbox"
29
+ dockerignore_original = ".dockerignore"
30
+ dockerignore_backup = ".dockerignore.bak"
31
+
32
+ has_sandbox_ignore = File.exist?(dockerignore_sandbox)
33
+ has_original_ignore = File.exist?(dockerignore_original)
34
+
35
+ if has_sandbox_ignore
36
+ puts "📦 Swapping .dockerignore with .dockerignore.sandbox..."
37
+ if has_original_ignore
38
+ FileUtils.mv(dockerignore_original, dockerignore_backup)
39
+ end
40
+ FileUtils.cp(dockerignore_sandbox, dockerignore_original)
41
+ end
42
+
43
+ success = false
44
+ begin
45
+ success = system(
46
+ "docker", "build",
47
+ "--build-arg", "USER_ID=#{user_id}",
48
+ "--build-arg", "GROUP_ID=#{group_id}",
49
+ "-t", Config.image_name(config),
50
+ "-f", "Dockerfile.sandbox",
51
+ "."
52
+ )
53
+ ensure
54
+ if has_sandbox_ignore
55
+ # Restore original state
56
+ FileUtils.rm(dockerignore_original) if File.exist?(dockerignore_original)
57
+ if has_original_ignore
58
+ FileUtils.mv(dockerignore_backup, dockerignore_original)
59
+ end
60
+ puts "🧹 Restored original .dockerignore"
61
+ end
62
+ end
63
+
64
+ unless success
65
+ puts "❌ Failed to build Docker image"
66
+ exit 1
67
+ end
68
+
69
+ puts "✅ Docker image built"
70
+ end
71
+ end
72
+ end
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tinker-agent",
3
- "version": "1.0.44",
3
+ "version": "1.0.46",
4
4
  "description": "Tinker Agent Runner",
5
5
  "bin": {
6
6
  "tinker-agent": "./run-tinker-agent.rb"
@@ -9,6 +9,7 @@
9
9
  "run-tinker-agent.rb",
10
10
  "agents.rb",
11
11
  "setup-agent.rb",
12
+ "lib/",
12
13
  "bin/agent-bridge-tmux",
13
14
  "bin/install-agent.sh"
14
15
  ],
@@ -11,349 +11,11 @@
11
11
  # - Dockerfile.sandbox in project root
12
12
  # - tinker.env.rb in project root (gitignored)
13
13
 
14
- require "json"
15
- require "fileutils"
16
-
17
- # Load agent configs
14
+ require_relative "lib/tinker_agent/config"
15
+ require_relative "lib/tinker_agent/docker"
16
+ require_relative "lib/tinker_agent/agent"
18
17
  require_relative "agents"
19
18
 
20
- def image_name(config)
21
- if config["project_id"]
22
- "tinker-sandbox-#{config['project_id']}"
23
- else
24
- "tinker-sandbox"
25
- end
26
- end
27
-
28
- AGENT_TYPES = AGENT_CONFIGS.keys.freeze
29
-
30
- def load_config
31
- # Support Ruby config for heredocs and comments (tinker.env.rb)
32
- rb_config_file = File.join(Dir.pwd, "tinker.env.rb")
33
-
34
- unless File.exist?(rb_config_file)
35
- puts "❌ Error: tinker.env.rb not found in current directory"
36
- puts ""
37
- puts "Create tinker.env.rb:"
38
- puts " {"
39
- puts " project_id: 1,"
40
- puts " rails_ws_url: '...',"
41
- puts " # ..."
42
- puts " # Paste your stripped .env content here:"
43
- puts " dot_env: <<~ENV"
44
- puts " STRIPE_KEY=sk_test_..."
45
- puts " OPENAI_KEY=sk-..."
46
- puts " ENV"
47
- puts " }"
48
- puts " echo 'tinker.env.rb' >> .gitignore"
49
- exit 1
50
- end
51
-
52
- puts "⚙️ Loading configuration from tinker.env.rb"
53
- config = eval(File.read(rb_config_file), binding, rb_config_file)
54
-
55
- # Convert symbols to strings for easier handling before JSON normalization
56
- config = config.transform_keys(&:to_s)
57
-
58
- # Parse dot_env heredoc if present
59
- if (dotenv = config["dot_env"])
60
- config["env"] ||= {}
61
- # Ensure env is string-keyed
62
- config["env"] = config["env"].transform_keys(&:to_s)
63
-
64
- dotenv.each_line do |line|
65
- line = line.strip
66
- next if line.empty? || line.start_with?('#')
67
- k, v = line.split('=', 2)
68
- next unless k && v
69
- # Remove surrounding quotes and trailing comments (simple)
70
- v = v.strip.gsub(/^['"]|['"]$/, '')
71
- config["env"][k.strip] = v
72
- end
73
-
74
- config.delete("dot_env")
75
- puts "🌿 Parsed dot_env into #{config['env'].size} environment variables"
76
- end
77
-
78
- # Normalize symbols to strings for consistency via JSON round-trip
79
- JSON.parse(JSON.generate(config))
80
- end
81
-
82
- def check_dockerfile!
83
- unless File.exist?("Dockerfile.sandbox")
84
- puts "❌ Error: Dockerfile.sandbox not found"
85
- puts ""
86
- puts "Please create Dockerfile.sandbox by copying your existing Dockerfile"
87
- puts "and adding the required agent dependencies."
88
- puts ""
89
- puts "See https://github.com/RoM4iK/tinker-public/blob/main/README.md for instructions."
90
- exit 1
91
- end
92
- end
93
-
94
- def build_docker_image(config)
95
- check_dockerfile!
96
-
97
- user_id = `id -u`.strip
98
- group_id = `id -g`.strip
99
-
100
- puts "🏗️ Building Docker image..."
101
-
102
- # Handle .dockerignore.sandbox
103
- dockerignore_sandbox = ".dockerignore.sandbox"
104
- dockerignore_original = ".dockerignore"
105
- dockerignore_backup = ".dockerignore.bak"
106
-
107
- has_sandbox_ignore = File.exist?(dockerignore_sandbox)
108
- has_original_ignore = File.exist?(dockerignore_original)
109
-
110
- if has_sandbox_ignore
111
- puts "📦 Swapping .dockerignore with .dockerignore.sandbox..."
112
- if has_original_ignore
113
- FileUtils.mv(dockerignore_original, dockerignore_backup)
114
- end
115
- FileUtils.cp(dockerignore_sandbox, dockerignore_original)
116
- end
117
-
118
- success = false
119
- begin
120
- success = system(
121
- "docker", "build",
122
- "--build-arg", "USER_ID=#{user_id}",
123
- "--build-arg", "GROUP_ID=#{group_id}",
124
- "-t", image_name(config),
125
- "-f", "Dockerfile.sandbox",
126
- "."
127
- )
128
- ensure
129
- if has_sandbox_ignore
130
- # Restore original state
131
- FileUtils.rm(dockerignore_original) if File.exist?(dockerignore_original)
132
- if has_original_ignore
133
- FileUtils.mv(dockerignore_backup, dockerignore_original)
134
- end
135
- puts "🧹 Restored original .dockerignore"
136
- end
137
- end
138
-
139
- unless success
140
- puts "❌ Failed to build Docker image"
141
- exit 1
142
- end
143
-
144
- puts "✅ Docker image built"
145
- end
146
-
147
- def run_agent(agent_type, config)
148
- unless AGENT_TYPES.include?(agent_type)
149
- puts "❌ Unknown agent type: #{agent_type}"
150
- puts " Available: #{AGENT_TYPES.join(', ')}"
151
- exit 1
152
- end
153
-
154
- agent_def = AGENT_CONFIGS[agent_type]
155
- agent_config = config.dig("agents", agent_type) || {}
156
- container_name = agent_config["container_name"] || agent_def[:name]
157
-
158
- puts "🚀 Starting #{agent_type} agent..."
159
-
160
- # Stop existing container if running
161
- system("docker", "rm", "-f", container_name, err: File::NULL, out: File::NULL)
162
-
163
- # Write banner to a persistent temp file (not auto-deleted)
164
- banner_path = "/tmp/tinker-agent-banner-#{agent_type}.txt"
165
- File.write(banner_path, agent_def[:banner])
166
-
167
- docker_cmd = [
168
- "docker", "run", "-d",
169
- "--name", container_name,
170
- "--network=host",
171
- ]
172
-
173
- # Inject custom environment variables from config
174
- if (custom_env = config["env"])
175
- custom_env.each do |k, v|
176
- docker_cmd += ["-e", "#{k}=#{v}"]
177
- end
178
- puts "🌿 Injected #{custom_env.size} custom env vars from config"
179
- end
180
-
181
- docker_cmd += [
182
- # Mount Claude config
183
- "-v", "#{ENV['HOME']}/.claude.json:/tmp/cfg/claude.json:ro",
184
- "-v", "#{ENV['HOME']}/.claude:/tmp/cfg/claude_dir:ro",
185
- "-v", "#{banner_path}:/etc/tinker/system-prompt.txt:ro",
186
- "-e", "TINKER_VERSION=main",
187
- "-e", "SKILLS=#{agent_def[:skills]&.join(',')}",
188
- "-e", "AGENT_TYPE=#{agent_type}",
189
- "-e", "PROJECT_ID=#{config['project_id']}",
190
- "-e", "RAILS_WS_URL=#{config['rails_ws_url']}",
191
- "-e", "RAILS_API_URL=#{config['rails_api_url']}",
192
- "-e", "RAILS_API_KEY=#{agent_config['mcp_api_key']}"
193
- ]
194
-
195
- # Add GitHub auth
196
- github = config["github"] || {}
197
- if github["method"] == "app"
198
- key_path = File.expand_path(github["app_private_key_path"].to_s)
199
-
200
- unless File.exist?(key_path) && !File.directory?(key_path)
201
- puts "❌ Error: GitHub App private key not found at: #{key_path}"
202
- puts " Please check 'app_private_key_path' in tinker.env.rb"
203
- exit 1
204
- end
205
-
206
- docker_cmd += [
207
- "-e", "GITHUB_APP_CLIENT_ID=#{github['app_client_id']}",
208
- "-e", "GITHUB_APP_INSTALLATION_ID=#{github['app_installation_id']}",
209
- # Path is set dynamically in entrypoint.sh based on user home
210
- "-v", "#{key_path}:/tmp/github-app-privkey.pem:ro"
211
- ]
212
- puts "🔐 Using GitHub App authentication"
213
- elsif github["token"]
214
- docker_cmd += ["-e", "GH_TOKEN=#{github['token']}"]
215
- puts "🔑 Using GitHub token authentication"
216
- else
217
- puts "❌ Error: No GitHub authentication configured"
218
- puts " Please configure 'github' in tinker.env.rb"
219
- exit 1
220
- end
221
-
222
- # Add git config
223
- if (git_config = config["git"])
224
- docker_cmd += ["-e", "GIT_USER_NAME=#{git_config['user_name']}"] if git_config["user_name"]
225
- docker_cmd += ["-e", "GIT_USER_EMAIL=#{git_config['user_email']}"] if git_config["user_email"]
226
- end
227
-
228
- # Check for local setup-agent.rb (for development)
229
- local_setup_script = File.join(File.dirname(__FILE__), "setup-agent.rb")
230
-
231
- # Check for local agent-bridge binaries (for development)
232
- # Priority:
233
- # 1. Linux binary matching host arch (for proper container execution)
234
- # 2. Legacy bin/agent-bridge if it's a binary (not script)
235
-
236
- arch = `uname -m`.strip
237
- linux_arch = (arch == "x86_64") ? "amd64" : "arm64"
238
- linux_bridge = File.join(Dir.pwd, "tinker-public", "bin", "agent-bridge-linux-#{linux_arch}")
239
-
240
- local_bridge_default = File.join(Dir.pwd, "bin", "agent-bridge")
241
- local_tmux = File.join(File.dirname(__FILE__), "bin", "agent-bridge-tmux")
242
-
243
- mounts = []
244
- if File.exist?(local_setup_script)
245
- puts "🔧 Using local setup-agent.rb for development"
246
- mounts += ["-v", "#{File.expand_path(local_setup_script)}:/tmp/setup-agent.rb:ro"]
247
- end
248
-
249
- if File.exist?(linux_bridge)
250
- puts "🔧 Using local linux binary: #{linux_bridge}"
251
- mounts += ["-v", "#{linux_bridge}:/tmp/agent-bridge:ro"]
252
- elsif File.exist?(local_bridge_default)
253
- # Check if it's a binary or script
254
- is_script = File.read(local_bridge_default, 4) == "#!/b"
255
- if is_script
256
- puts "⚠️ bin/agent-bridge is a host wrapper script. Please run 'bin/build-bridge' to generate linux binaries."
257
- else
258
- puts "🔧 Using local agent-bridge binary"
259
- mounts += ["-v", "#{local_bridge_default}:/tmp/agent-bridge:ro"]
260
- end
261
- end
262
-
263
- if File.exist?(local_tmux)
264
- mounts += ["-v", "#{File.expand_path(local_tmux)}:/tmp/agent-bridge-tmux:ro"]
265
- end
266
-
267
- docker_cmd += mounts
268
-
269
- if File.exist?(local_setup_script)
270
- docker_cmd += [image_name(config), "ruby", "/tmp/setup-agent.rb"]
271
- else
272
- docker_cmd += [image_name(config)]
273
- end
274
-
275
- success = system(*docker_cmd)
276
-
277
- if success
278
- puts "✅ Agent started in background"
279
- puts ""
280
- puts " Attach: npx tinker-agent attach #{agent_type}"
281
- puts " Logs: docker logs -f #{container_name}"
282
- puts " Stop: docker stop #{container_name}"
283
- else
284
- puts "❌ Failed to start agent"
285
- exit 1
286
- end
287
- end
288
-
289
- def attach_to_agent(agent_type, config)
290
- unless AGENT_TYPES.include?(agent_type)
291
- puts "❌ Unknown agent type: #{agent_type}"
292
- exit 1
293
- end
294
-
295
- agent_def = AGENT_CONFIGS[agent_type]
296
- agent_config = config.dig("agents", agent_type) || {}
297
- container_name = agent_config["container_name"] || agent_def[:name]
298
-
299
- running = `docker ps --filter name=^#{container_name}$ --format '{{.Names}}'`.strip
300
-
301
- if running.empty?
302
- puts "⚠️ #{agent_type} agent is not running. Auto-starting..."
303
- build_docker_image(config)
304
- run_agent(agent_type, config)
305
- sleep 3
306
- end
307
-
308
- puts "📎 Attaching to #{agent_type} agent..."
309
-
310
- # Determine the user to attach as
311
- # Robust method: find the user running the agent process (tmux or bridge)
312
- user = `docker exec #{container_name} ps aux | grep "[a]gent-bridge-tmux" | awk '{print $1}' | head -n 1`.strip
313
-
314
- if user.empty?
315
- user = `docker exec #{container_name} ps aux | grep "[t]mux new-session" | awk '{print $1}' | head -n 1`.strip
316
- end
317
-
318
- if user.empty?
319
- # Fallback to previous heuristic
320
- detected_user = `docker exec #{container_name} whoami 2>/dev/null`.strip
321
- if detected_user == "root" || detected_user.empty?
322
- uid = Process.uid
323
- mapped_user = `docker exec #{container_name} getent passwd #{uid} | cut -d: -f1`.strip
324
- user = mapped_user unless mapped_user.empty?
325
- else
326
- user = detected_user
327
- end
328
- end
329
-
330
- if user.empty?
331
- # Final Fallback
332
- user = "rails"
333
- puts "⚠️ Could not detect agent user, defaulting to '#{user}'"
334
- end
335
-
336
- if user.empty?
337
- # Fallback: default to rails (standard for this image)
338
- user = "rails"
339
- puts "⚠️ Could not detect agent user, defaulting to '#{user}'"
340
- end
341
-
342
- puts " User: #{user}"
343
-
344
- # Wait for tmux session to be ready
345
- 10.times do
346
- if system("docker", "exec", "-u", user, container_name, "tmux", "has-session", "-t", "agent", err: File::NULL, out: File::NULL)
347
- break
348
- end
349
- sleep 1
350
- end
351
-
352
- # Attach to agent session which has the status bar
353
- # Must run as agent user since tmux server runs under that user
354
- exec("docker", "exec", "-it", "-u", user, container_name, "tmux", "attach", "-t", "agent")
355
- end
356
-
357
19
  def show_usage
358
20
  puts "Tinker Agent Runner"
359
21
  puts ""
@@ -376,10 +38,10 @@ command = ARGV[0].downcase
376
38
  if command == "attach"
377
39
  agent_type = ARGV[1]&.downcase
378
40
  abort "Usage: npx tinker-agent attach [agent-type]" unless agent_type
379
- config = load_config
380
- attach_to_agent(agent_type, config)
41
+ config = TinkerAgent::Config.load
42
+ TinkerAgent::Agent.attach(agent_type, config, AGENT_CONFIGS)
381
43
  else
382
- config = load_config
383
- build_docker_image(config)
384
- run_agent(command, config)
44
+ config = TinkerAgent::Config.load
45
+ TinkerAgent::Docker.build_image(config)
46
+ TinkerAgent::Agent.run(command, config, AGENT_CONFIGS)
385
47
  end