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.
- package/bin/install-agent.sh +27 -13
- package/lib/tinker_agent/agent.rb +239 -0
- package/lib/tinker_agent/config.rb +78 -0
- package/lib/tinker_agent/docker.rb +72 -0
- package/package.json +2 -1
- package/run-tinker-agent.rb +8 -346
package/bin/install-agent.sh
CHANGED
|
@@ -29,21 +29,19 @@ else
|
|
|
29
29
|
apt-get install -y nodejs
|
|
30
30
|
fi
|
|
31
31
|
|
|
32
|
-
#
|
|
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
|
|
101
|
-
|
|
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.
|
|
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
|
],
|
package/run-tinker-agent.rb
CHANGED
|
@@ -11,349 +11,11 @@
|
|
|
11
11
|
# - Dockerfile.sandbox in project root
|
|
12
12
|
# - tinker.env.rb in project root (gitignored)
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
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 =
|
|
380
|
-
|
|
41
|
+
config = TinkerAgent::Config.load
|
|
42
|
+
TinkerAgent::Agent.attach(agent_type, config, AGENT_CONFIGS)
|
|
381
43
|
else
|
|
382
|
-
config =
|
|
383
|
-
|
|
384
|
-
|
|
44
|
+
config = TinkerAgent::Config.load
|
|
45
|
+
TinkerAgent::Docker.build_image(config)
|
|
46
|
+
TinkerAgent::Agent.run(command, config, AGENT_CONFIGS)
|
|
385
47
|
end
|